2900 lines
103 KiB
JavaScript
2900 lines
103 KiB
JavaScript
(function () {
|
|
"use strict";
|
|
|
|
const theme = window.NEBULA_THEME || {};
|
|
const app = document.getElementById('app');
|
|
const loader = document.getElementById('nebula-loader');
|
|
const themeMediaQuery = getThemeMediaQuery();
|
|
|
|
if (!app) {
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
mode: 'login',
|
|
loading: true,
|
|
message: '',
|
|
messageType: '',
|
|
themePreference: getStoredThemePreference(),
|
|
themeMode: 'dark',
|
|
uplinkMbps: null,
|
|
currentRoute: getCurrentRoute(),
|
|
nodePage: 1,
|
|
localIp: null,
|
|
reportedIps: [],
|
|
authToken: getStoredToken(),
|
|
user: null,
|
|
subscribe: null,
|
|
stats: null,
|
|
knowledge: [],
|
|
tickets: [],
|
|
selectedTicketId: null,
|
|
selectedTicket: null,
|
|
servers: [],
|
|
ipv6AuthToken: getStoredIpv6Token(),
|
|
ipv6User: null,
|
|
ipv6Subscribe: null,
|
|
ipv6Eligibility: null,
|
|
ipv6Servers: [],
|
|
sessionOverview: null,
|
|
ipv6SessionOverview: null,
|
|
realNameVerification: null,
|
|
appConfig: null,
|
|
isSidebarOpen: false,
|
|
serverSearch: '',
|
|
cachedSubNodes: null,
|
|
ipv6CachedSubNodes: null
|
|
};
|
|
|
|
init();
|
|
|
|
async function init() {
|
|
applyThemeMode();
|
|
applyCustomBackground();
|
|
loadUplinkMetric();
|
|
window.setInterval(loadUplinkMetric, 5000);
|
|
|
|
try {
|
|
const verify = getVerifyToken();
|
|
if (verify) {
|
|
const verifyResponse = await fetchJson('/api/v1/passport/auth/token2Login?verify=' + encodeURIComponent(verify), {
|
|
method: 'GET',
|
|
auth: false
|
|
});
|
|
const verifyPayload = unwrap(verifyResponse);
|
|
if (verifyPayload && verifyPayload.auth_data) {
|
|
saveToken(verifyPayload.auth_data);
|
|
clearVerifyToken();
|
|
state.authToken = getStoredToken();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
showMessage(error.message || "Quick login failed", "error");
|
|
}
|
|
|
|
if (state.authToken) {
|
|
var loaded = await loadDashboard();
|
|
if (!loaded) {
|
|
state.loading = false;
|
|
}
|
|
} else {
|
|
state.loading = false;
|
|
}
|
|
|
|
render();
|
|
app.addEventListener("click", onClick);
|
|
app.addEventListener("input", onInput);
|
|
app.addEventListener("submit", onSubmit);
|
|
window.addEventListener("hashchange", onRouteChange);
|
|
bindSystemThemeListener();
|
|
}
|
|
|
|
async function loadDashboard(silent) {
|
|
if (!state.authToken) {
|
|
state.loading = false;
|
|
return false;
|
|
}
|
|
|
|
state.loading = !silent;
|
|
if (!silent) {
|
|
render();
|
|
}
|
|
|
|
try {
|
|
const results = await Promise.all([
|
|
fetchJson('/api/v1/user/info', { method: 'GET' }),
|
|
fetchJson('/api/v1/user/getSubscribe', { method: 'GET' }),
|
|
fetchJson('/api/v1/user/getStat', { method: 'GET' }),
|
|
fetchJson('/api/v1/user/server/fetch', { method: 'GET' }),
|
|
fetchJson('/api/v1/user/ticket/fetch', { method: 'GET' }),
|
|
fetchSessionOverview(),
|
|
fetchJson('/api/v1/user/comm/config', { method: 'GET' }),
|
|
fetchRealNameVerificationStatus(),
|
|
fetchJson('/api/v1/user/user-add-ipv6-subscription/check', { method: 'GET' })
|
|
]);
|
|
|
|
state.user = unwrap(results[0]);
|
|
state.subscribe = unwrap(results[1]);
|
|
state.stats = unwrap(results[2]);
|
|
state.servers = unwrap(results[3]) || [];
|
|
state.knowledge = [];
|
|
state.tickets = unwrap(results[4]) || [];
|
|
state.sessionOverview = results[5];
|
|
state.appConfig = unwrap(results[6]) || {};
|
|
state.realNameVerification = results[7] || null;
|
|
state.ipv6Eligibility = unwrap(results[8]) || null;
|
|
|
|
if (state.ipv6AuthToken) {
|
|
try {
|
|
const ipv6Results = await Promise.all([
|
|
fetchJson('/api/v1/user/info', { method: 'GET', ipv6: true }),
|
|
fetchJson('/api/v1/user/getSubscribe', { method: 'GET', ipv6: true }),
|
|
fetchJson('/api/v1/user/server/fetch', { method: 'GET', ipv6: true }),
|
|
fetchSessionOverview(true)
|
|
]);
|
|
state.ipv6User = unwrap(ipv6Results[0]);
|
|
state.ipv6Subscribe = unwrap(ipv6Results[1]);
|
|
state.ipv6Servers = unwrap(ipv6Results[2]) || [];
|
|
state.ipv6SessionOverview = ipv6Results[3];
|
|
} catch (e) {
|
|
if (handleIpv6AuthFailure(e)) {
|
|
console.warn('Nebula: cleared stale IPv6 session after unauthorized response');
|
|
}
|
|
console.error('Failed to load IPv6 dashboard data', e);
|
|
}
|
|
}
|
|
|
|
state.nodePage = 1;
|
|
state.loading = false;
|
|
return true;
|
|
} catch (error) {
|
|
state.loading = false;
|
|
if (error.status === 401 || error.status === 403) {
|
|
const hadSession = Boolean(state.authToken);
|
|
clearToken();
|
|
resetDashboard();
|
|
showMessage(hadSession ? 'Session expired. Please sign in again.' : '', hadSession ? 'error' : '');
|
|
return false;
|
|
}
|
|
showMessage(error.message || 'Failed to load dashboard', 'error');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function fetchSessionOverview(isIpv6) {
|
|
try {
|
|
const response = await fetchJson('/api/v1/user/user-online-devices/get-ip', { method: 'GET', ipv6: !!isIpv6 });
|
|
const ipPayload = unwrap(response) || {};
|
|
|
|
const reportedIps = ipPayload.ips || [];
|
|
if (!isIpv6) {
|
|
state.reportedIps = reportedIps;
|
|
}
|
|
|
|
const overview = ipPayload.session_overview || { sessions: [], online_ips: [] };
|
|
if (!isIpv6) {
|
|
overview.online_ips = state.reportedIps;
|
|
}
|
|
|
|
return buildSessionOverview(overview.sessions, overview, !!isIpv6);
|
|
} catch (error) {
|
|
if (isIpv6 && handleIpv6AuthFailure(error)) {
|
|
return null;
|
|
}
|
|
console.error('Nebula: Failed to synchronize online status', error);
|
|
// Fallback for sessions if plugin fails
|
|
try {
|
|
const sessionsFallback = unwrap(await fetchJson('/api/v1/user/getActiveSession', { method: 'GET', ipv6: !!isIpv6 })) || [];
|
|
return buildSessionOverview(sessionsFallback, {}, !!isIpv6);
|
|
} catch (e) {
|
|
if (isIpv6 && handleIpv6AuthFailure(e)) {
|
|
return null;
|
|
}
|
|
return buildSessionOverview([], {}, !!isIpv6);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchRealNameVerificationStatus() {
|
|
try {
|
|
var response = await fetchJson("/api/v1/user/real-name-verification/status", { method: "GET" });
|
|
var payload = unwrap(response) || {};
|
|
return Object.assign({
|
|
enabled: true,
|
|
status: "unverified",
|
|
status_label: "未认证",
|
|
can_submit: true,
|
|
real_name: "",
|
|
identity_no_masked: "",
|
|
notice: "",
|
|
reject_reason: "",
|
|
submitted_at: null,
|
|
reviewed_at: null
|
|
}, payload);
|
|
} catch (error) {
|
|
return {
|
|
enabled: false,
|
|
status: "unavailable",
|
|
status_label: "Unavailable",
|
|
can_submit: false,
|
|
real_name: "",
|
|
identity_no_masked: "",
|
|
notice: "",
|
|
reject_reason: "",
|
|
submitted_at: null,
|
|
reviewed_at: null
|
|
};
|
|
}
|
|
}
|
|
|
|
async function fetchJson(url, options) {
|
|
options = options || {};
|
|
var headers = {
|
|
"Content-Type": "application/json",
|
|
"X-Requested-With": "XMLHttpRequest"
|
|
};
|
|
|
|
if (options.auth !== false) {
|
|
var token = options.ipv6 ? (state.ipv6AuthToken || getStoredIpv6Token()) : (state.authToken || getStoredToken());
|
|
if (token) {
|
|
headers.Authorization = normalizeAuthHeader(token);
|
|
}
|
|
}
|
|
|
|
var response = await fetch(url, {
|
|
method: options.method || "GET",
|
|
headers: headers,
|
|
credentials: "same-origin",
|
|
body: options.body ? JSON.stringify(options.body) : undefined
|
|
});
|
|
|
|
var payload = null;
|
|
try {
|
|
payload = await response.json();
|
|
} catch (error) {
|
|
payload = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
var message = payload && (payload.message || payload.error) || "Request failed";
|
|
var err = new Error(message);
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
|
|
return payload;
|
|
}
|
|
|
|
function unwrap(payload) {
|
|
if (!payload) {
|
|
return null;
|
|
}
|
|
if (typeof payload.data !== "undefined") {
|
|
return payload.data;
|
|
}
|
|
if (typeof payload.total !== "undefined" && Array.isArray(payload.data)) {
|
|
return payload.data;
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function onClick(event) {
|
|
if (state.isSidebarOpen) {
|
|
var sidebar = event.target.closest(".dashboard-sidebar");
|
|
var toggleBtn = event.target.closest("[data-action='toggle-sidebar']");
|
|
if (!sidebar && !toggleBtn) {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
var sidebarEl = document.querySelector(".dashboard-sidebar");
|
|
var overlayEl = document.querySelector(".sidebar-overlay");
|
|
if (sidebarEl) sidebarEl.classList.remove("is-open");
|
|
if (overlayEl) overlayEl.classList.remove("is-visible");
|
|
}
|
|
}
|
|
|
|
var actionEl = event.target.closest("[data-action]");
|
|
if (!actionEl) return;
|
|
|
|
var action = actionEl.getAttribute("data-action");
|
|
|
|
if (action === "switch-mode") {
|
|
state.mode = actionEl.getAttribute("data-mode") || "login";
|
|
showMessage("", "");
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "navigate") {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
setCurrentRoute(actionEl.getAttribute("data-route") || "overview");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-sidebar") {
|
|
state.isSidebarOpen = !state.isSidebarOpen;
|
|
document.documentElement.dataset.sidebarOpen = String(state.isSidebarOpen);
|
|
var sidebar = document.querySelector(".dashboard-sidebar");
|
|
var overlay = document.querySelector(".sidebar-overlay");
|
|
if (sidebar) sidebar.classList.toggle("is-open", state.isSidebarOpen);
|
|
if (overlay) overlay.classList.toggle("is-visible", state.isSidebarOpen);
|
|
return;
|
|
}
|
|
|
|
if (action === "close-sidebar") {
|
|
state.isSidebarOpen = false;
|
|
document.documentElement.dataset.sidebarOpen = "false";
|
|
var sidebar = document.querySelector(".dashboard-sidebar");
|
|
var overlay = document.querySelector(".sidebar-overlay");
|
|
if (sidebar) sidebar.classList.remove("is-open");
|
|
if (overlay) overlay.classList.remove("is-visible");
|
|
return;
|
|
}
|
|
|
|
if (action === "toggle-theme-mode") {
|
|
toggleThemeMode();
|
|
return;
|
|
}
|
|
|
|
if (action === "change-node-page") {
|
|
setNodePage(Number(actionEl.getAttribute("data-page") || 1));
|
|
return;
|
|
}
|
|
|
|
if (action === "logout") {
|
|
clearToken();
|
|
clearIpv6Token();
|
|
resetDashboard();
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "refresh-dashboard") {
|
|
loadDashboard(true).then(render);
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-subscribe") {
|
|
copyText((state.subscribe && state.subscribe.subscribe_url) || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-ipv6-subscribe") {
|
|
copyText((state.ipv6Subscribe && state.ipv6Subscribe.subscribe_url) || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "copy-text") {
|
|
copyText(actionEl.getAttribute("data-value") || "");
|
|
return;
|
|
}
|
|
|
|
if (action === "reset-security") {
|
|
handleResetSecurity(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "reset-ipv6-security") {
|
|
handleResetSecurity(actionEl, true);
|
|
return;
|
|
}
|
|
|
|
if (action === "remove-session") {
|
|
handleRemoveSession(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "remove-other-sessions") {
|
|
handleRemoveOtherSessions(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "open-ticket-detail") {
|
|
handleOpenTicketDetail(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "open-knowledge-article") {
|
|
handleOpenKnowledgeArticle(actionEl);
|
|
return;
|
|
}
|
|
|
|
if (action === "close-ticket-detail") {
|
|
state.selectedTicketId = null;
|
|
state.selectedTicket = null;
|
|
render();
|
|
}
|
|
|
|
if (action === "copy-node-info") {
|
|
handleCopyNodeInfo(actionEl.getAttribute("data-node-id"));
|
|
return;
|
|
}
|
|
|
|
if (action === "set-mode") {
|
|
state.mode = actionEl.getAttribute("data-mode") || "login";
|
|
render();
|
|
return;
|
|
}
|
|
|
|
if (action === "send-reset-code") {
|
|
var form = actionEl.closest("form");
|
|
var email = form.querySelector("[name='email']").value;
|
|
if (!email) {
|
|
showMessage("请输入邮箱地址", "warning");
|
|
return;
|
|
}
|
|
actionEl.disabled = true;
|
|
fetchJson("/api/v1/passport/comm/sendEmailVerify", {
|
|
method: "POST",
|
|
auth: false,
|
|
body: { email: email }
|
|
}).then(function () {
|
|
showMessage("验证码已发送,请检查您的邮箱", "success");
|
|
startCountdown(actionEl, 60);
|
|
}).catch(function (error) {
|
|
showMessage(error.message || "获取验证码失败", "error");
|
|
actionEl.disabled = false;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
function startCountdown(button, seconds) {
|
|
var originalText = "获取验证码";
|
|
button.disabled = true;
|
|
var current = seconds;
|
|
var timer = setInterval(function () {
|
|
if (current <= 0) {
|
|
clearInterval(timer);
|
|
button.innerText = originalText;
|
|
button.disabled = false;
|
|
return;
|
|
}
|
|
button.innerText = current + "s";
|
|
current--;
|
|
}, 1000);
|
|
}
|
|
|
|
async function handleCopyNodeInfo(nodeId) {
|
|
var server = (state.servers || []).concat(state.ipv6Servers || []).find(function (s) {
|
|
return String(s.id) === String(nodeId);
|
|
});
|
|
if (!server) return;
|
|
|
|
var isIpv6Node = (state.ipv6Servers || []).some(function (s) {
|
|
return String(s.id) === String(nodeId);
|
|
});
|
|
|
|
var sub = isIpv6Node ? state.ipv6Subscribe : state.subscribe;
|
|
var subUrl = (sub && sub.subscribe_url) || "";
|
|
var targetName = (server.name || "").trim();
|
|
var cacheKey = isIpv6Node ? "ipv6CachedSubNodes" : "cachedSubNodes";
|
|
var storageKey = isIpv6Node ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__";
|
|
|
|
// Try to get link from subscription if possible
|
|
if (subUrl) {
|
|
try {
|
|
if (!state[cacheKey]) {
|
|
// Check if we have a manually pasted sub content in sessionStorage
|
|
var savedSub = window.sessionStorage.getItem(storageKey);
|
|
if (savedSub) {
|
|
console.log("Nebula: Using session-cached " + (isIpv6Node ? "IPv6 " : "") + "subscription content");
|
|
state[cacheKey] = parseSubscriptionToLines(savedSub);
|
|
} else {
|
|
console.log("Nebula: Synchronizing " + (isIpv6Node ? "IPv6 " : "") + "subscription metadata...");
|
|
state[cacheKey] = await fetchSubscriptionNodes(subUrl);
|
|
}
|
|
}
|
|
|
|
if (state[cacheKey] && state[cacheKey].length) {
|
|
var link = findNodeLinkByName(state[cacheKey], targetName);
|
|
if (link) {
|
|
var clashYaml = linkToClash(link);
|
|
showNodeInfoModal(server, clashYaml, link);
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Nebula: Failed to parse subscription", error);
|
|
// If it looks like a CORS error (TypeError: Failed to fetch)
|
|
if (error instanceof TypeError) {
|
|
showCorsHelpModal(subUrl, nodeId, isIpv6Node);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to text info if link not found or subscription failed
|
|
var tags = normalizeServerTags(server.tags);
|
|
var info = [
|
|
"节点名称: " + (server.name || "未命名节点"),
|
|
"节点类型: " + (server.type || "未知"),
|
|
"节点倍率: " + (server.rate || 1) + "x",
|
|
"节点状态: " + (server.is_online ? "在线" : "离线"),
|
|
tags.length ? "节点标签: " + tags.join(", ") : ""
|
|
].filter(Boolean).join("\n");
|
|
|
|
copyText(info, "已复制节点基本信息(订阅链接当前不可达)");
|
|
}
|
|
|
|
function showNodeInfoModal(server, clashYaml, standardLink) {
|
|
var overlay = document.createElement("div");
|
|
overlay.className = "nebula-modal-overlay";
|
|
|
|
var modal = document.createElement("div");
|
|
modal.className = "nebula-modal stack";
|
|
modal.style.maxWidth = "600px";
|
|
modal.style.width = "min(540px, 100%)";
|
|
|
|
modal.innerHTML = [
|
|
'<h3 class="nebula-modal-title" style="margin-bottom: 20px;">' + escapeHtml(server.name || "节点详情") + '</h3>',
|
|
'<div class="node-specs" style="margin-bottom:24px; display: grid; grid-template-columns: 1fr 1fr; gap: 12px;">',
|
|
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
|
|
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">节点类型</span>' +
|
|
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(server.type || "unknown").toUpperCase() + '</strong>' +
|
|
'</div>',
|
|
'<div class="node-spec" style="background: rgba(var(--accent-rgb), 0.05); border-color: rgba(var(--accent-rgb), 0.15); text-align: center; padding: 14px;">' +
|
|
'<span class="meta-label" style="margin-top:0; font-size:10px; opacity:0.7;">结算倍率</span>' +
|
|
'<strong style="color: var(--accent); font-size: 18px; margin-top: 4px; display: block;">' + escapeHtml(String(server.rate || 1)) + 'x</strong>' +
|
|
'</div>',
|
|
'</div>',
|
|
'<div class="field" style="margin-top: 8px;"><label>Clash 配置预览 (已脱敏)</label>',
|
|
'<pre class="node-info-pre" style="max-height: 180px; font-size: 11px;">' + escapeHtml(maskClashYaml(clashYaml)) + '</pre>',
|
|
'</div>',
|
|
'<div class="nebula-modal-footer" style="padding-top: 24px; display: flex; gap: 10px; flex-wrap: wrap;">',
|
|
'<button class="btn btn-primary" id="node-info-copy-link" style="flex: 1; min-width: 100px; white-space: nowrap;">标准格式</button>',
|
|
'<button class="btn btn-primary" id="node-info-copy-clash" style="flex: 1; min-width: 100px; white-space: nowrap;">Clash 格式</button>',
|
|
'<button class="btn btn-ghost" id="node-info-close" style="flex: 1; min-width: 100px; white-space: nowrap;">关闭</button>',
|
|
'</div>'
|
|
].join("");
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.appendChild(modal);
|
|
|
|
modal.querySelector("#node-info-close").onclick = function() {
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#node-info-copy-link").onclick = function() {
|
|
copyText(standardLink, "标准配置链接已复制");
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#node-info-copy-clash").onclick = function() {
|
|
copyText(clashYaml, "Clash 配置已复制");
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
overlay.onclick = function(e) {
|
|
if (e.target === overlay) document.body.removeChild(overlay);
|
|
};
|
|
}
|
|
|
|
function showCorsHelpModal(subUrl, nodeId, isIpv6) {
|
|
var title = (isIpv6 ? "IPv6 " : "") + "同步订阅数据";
|
|
showNebulaModal({
|
|
title: title,
|
|
content: "由于浏览器跨域 (CORS) 策略限制,无法直接从网页获取节点配置链接。请手动同步一次订阅:\n\n1. 点击下方按钮在浏览器中打开订阅链接\n2. 复制网页显示的全部内容\n3. 返回此处点击“去粘贴”并存入系统",
|
|
confirmText: "打开订阅链接",
|
|
cancelText: "我要手动粘贴",
|
|
onConfirm: function() {
|
|
window.open(subUrl, "_blank");
|
|
showMessage("订阅已在新标签页打开,请复制其内容", "info");
|
|
},
|
|
onCancel: function() {
|
|
showManualPasteModal(nodeId, isIpv6);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showManualPasteModal(nodeId, isIpv6) {
|
|
var overlay = document.createElement("div");
|
|
overlay.className = "nebula-modal-overlay";
|
|
|
|
var modal = document.createElement("div");
|
|
modal.className = "nebula-modal stack";
|
|
modal.style.maxWidth = "550px";
|
|
|
|
var storageKey = isIpv6 ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__";
|
|
var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes";
|
|
|
|
modal.innerHTML = [
|
|
'<h3 class="nebula-modal-title">粘贴 ' + (isIpv6 ? "IPv6 " : "") + '订阅内容</h3>',
|
|
'<p class="hero-description" style="font-size:13px;margin-bottom:12px">请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。</p>',
|
|
'<textarea id="nebula-sub-paste" class="field" style="width:100%;min-height:220px;font-size:12px;background:rgba(255,255,255,0.03);border-radius:12px;padding:12px" placeholder="粘贴此处..."></textarea>',
|
|
'<div class="nebula-modal-footer">',
|
|
'<button class="btn btn-ghost" id="nebula-paste-cancel">取消</button>',
|
|
'<button class="btn btn-primary" id="nebula-paste-save">解析并保存</button>',
|
|
'</div>'
|
|
].join("");
|
|
|
|
document.body.appendChild(overlay);
|
|
overlay.appendChild(modal);
|
|
|
|
modal.querySelector("#nebula-paste-cancel").onclick = function() {
|
|
document.body.removeChild(overlay);
|
|
};
|
|
|
|
modal.querySelector("#nebula-paste-save").onclick = function() {
|
|
var content = modal.querySelector("#nebula-sub-paste").value.trim();
|
|
if (content) {
|
|
window.sessionStorage.setItem(storageKey, content);
|
|
state[cacheKey] = parseSubscriptionToLines(content);
|
|
document.body.removeChild(overlay);
|
|
showMessage("订阅数据同步成功!", "success");
|
|
if (nodeId) handleCopyNodeInfo(nodeId);
|
|
} else {
|
|
showMessage("内容不能为空", "error");
|
|
}
|
|
};
|
|
}
|
|
|
|
async function fetchSubscriptionNodes(url) {
|
|
try {
|
|
var response = await fetch(url);
|
|
if (!response.ok) throw new Error("Network response was not ok");
|
|
var text = await response.text();
|
|
return parseSubscriptionToLines(text);
|
|
} catch (e) {
|
|
throw e; // Reraise to be caught by handleCopyNodeInfo for CORS check
|
|
}
|
|
}
|
|
|
|
function parseSubscriptionToLines(text) {
|
|
var decoded = "";
|
|
try {
|
|
decoded = utf8Base64Decode(text.trim());
|
|
} catch (e) {
|
|
decoded = text;
|
|
}
|
|
return decoded.split(/\r?\n/).map(function(s) { return s.trim(); }).filter(Boolean);
|
|
}
|
|
|
|
function findNodeLinkByName(links, name) {
|
|
if (!name) return null;
|
|
var target = name.trim();
|
|
|
|
console.log("Nebula: Searching for node '" + name + "' among " + links.length + " links (Strict Match)");
|
|
|
|
for (var i = 0; i < links.length; i++) {
|
|
var link = links[i].trim();
|
|
var remark = "";
|
|
|
|
if (link.indexOf("#") !== -1) {
|
|
var parts = link.split("#");
|
|
var rawRemark = parts[parts.length - 1];
|
|
try {
|
|
remark = decodeURIComponent(rawRemark.replace(/\+/g, "%20"));
|
|
} catch (e) {
|
|
remark = rawRemark;
|
|
}
|
|
} else if (link.indexOf("vmess://") === 0) {
|
|
try {
|
|
var jsonStr = utf8Base64Decode(link.slice(8));
|
|
var json = JSON.parse(jsonStr);
|
|
remark = json.ps || "";
|
|
} catch (e) {}
|
|
} else if (link.indexOf("name:") !== -1) {
|
|
var yamlMatch = link.match(/name:\s*["']?([^"']+)["']?/);
|
|
if (yamlMatch) remark = yamlMatch[1];
|
|
}
|
|
|
|
// Strict exact match after trimming
|
|
if ((remark || "").trim() === target) {
|
|
return link;
|
|
}
|
|
}
|
|
|
|
console.warn("Nebula: No exact match found for node '" + name + "'");
|
|
return null;
|
|
}
|
|
|
|
function maskClashYaml(yaml) {
|
|
if (!yaml) return "";
|
|
return yaml
|
|
.replace(/server:\s*.+/g, "server: **********")
|
|
.replace(/password:\s*.+/g, "password: **********")
|
|
.replace(/uuid:\s*.+/g, "uuid: **********")
|
|
.replace(/public-key:\s*.+/g, "public-key: **********");
|
|
}
|
|
|
|
function linkToClash(link) {
|
|
if (!link) return "";
|
|
|
|
var remark = "";
|
|
if (link.indexOf("#") !== -1) {
|
|
var parts = link.split("#");
|
|
try {
|
|
remark = decodeURIComponent(parts[parts.length - 1].replace(/\+/g, "%20"));
|
|
} catch (e) {
|
|
remark = parts[parts.length - 1];
|
|
}
|
|
}
|
|
|
|
// Shadowsocks
|
|
if (link.indexOf("ss://") === 0) {
|
|
var body = link.split("#")[0].slice(5);
|
|
var parts = body.split("@");
|
|
if (parts.length < 2) return link;
|
|
|
|
var userinfo = parts[0];
|
|
var serverinfo = parts[1];
|
|
var decodedUser = "";
|
|
try {
|
|
decodedUser = atob(userinfo.replace(/-/g, "+").replace(/_/g, "/"));
|
|
} catch (e) {
|
|
decodedUser = userinfo;
|
|
}
|
|
|
|
var userParts = decodedUser.split(":");
|
|
var serverParts = serverinfo.split(":");
|
|
|
|
return [
|
|
"- name: \"" + (remark || "SS Node") + "\"",
|
|
" type: ss",
|
|
" server: " + serverParts[0],
|
|
" port: " + (serverParts[1] || 443),
|
|
" cipher: " + (userParts[0] || "aes-256-gcm"),
|
|
" password: " + (userParts[1] || ""),
|
|
" udp: true"
|
|
].join("\n");
|
|
}
|
|
|
|
// VMess
|
|
if (link.indexOf("vmess://") === 0) {
|
|
try {
|
|
var json = JSON.parse(utf8Base64Decode(link.slice(8)));
|
|
var yaml = [
|
|
"- name: \"" + (json.ps || remark || "VMess Node") + "\"",
|
|
" type: vmess",
|
|
" server: " + json.add,
|
|
" port: " + (json.port || 443),
|
|
" uuid: " + json.id,
|
|
" alterId: " + (json.aid || 0),
|
|
" cipher: auto",
|
|
" udp: true",
|
|
" tls: " + (json.tls === "tls" ? "true" : "false"),
|
|
" network: " + (json.net || "tcp"),
|
|
" servername: " + (json.sni || json.host || "")
|
|
];
|
|
if (json.net === "ws") {
|
|
yaml.push(" ws-opts:");
|
|
yaml.push(" path: " + (json.path || "/"));
|
|
if (json.host) yaml.push(" headers:");
|
|
if (json.host) yaml.push(" Host: " + json.host);
|
|
}
|
|
if (json.net === "grpc") {
|
|
yaml.push(" grpc-opts:");
|
|
yaml.push(" grpc-service-name: " + (json.path || ""));
|
|
}
|
|
return yaml.join("\n");
|
|
} catch (e) {
|
|
return link;
|
|
}
|
|
}
|
|
|
|
// VLESS
|
|
if (link.indexOf("vless://") === 0) {
|
|
var body = link.split("#")[0].slice(8);
|
|
var main = body.split("?")[0];
|
|
var query = body.split("?")[1] || "";
|
|
var parts = main.split("@");
|
|
var uuid = parts[0];
|
|
var serverParts = (parts[1] || "").split(":");
|
|
|
|
var params = {};
|
|
query.split("&").forEach(function (pair) {
|
|
var p = pair.split("=");
|
|
params[p[0]] = decodeURIComponent(p[1] || "");
|
|
});
|
|
|
|
var yaml = [
|
|
"- name: \"" + (remark || "VLESS Node") + "\"",
|
|
" type: vless",
|
|
" server: " + serverParts[0],
|
|
" port: " + (parseInt(serverParts[1]) || 443),
|
|
" uuid: " + uuid,
|
|
" udp: true",
|
|
" tls: " + (params.security !== "none" ? "true" : "false"),
|
|
" skip-cert-verify: true"
|
|
];
|
|
|
|
if (params.sni) yaml.push(" servername: " + params.sni);
|
|
if (params.type) yaml.push(" network: " + params.type);
|
|
if (params.flow) yaml.push(" flow: " + params.flow);
|
|
if (params.fp) yaml.push(" client-fingerprint: " + params.fp);
|
|
if (params.security === "reality") {
|
|
yaml.push(" reality-opts:");
|
|
if (params.pbk) yaml.push(" public-key: " + params.pbk);
|
|
if (params.sid) yaml.push(" short-id: " + params.sid);
|
|
}
|
|
if (params.type === "ws") {
|
|
yaml.push(" ws-opts:");
|
|
yaml.push(" path: " + (params.path || "/"));
|
|
if (params.host) yaml.push(" headers:");
|
|
if (params.host) yaml.push(" Host: " + params.host);
|
|
}
|
|
if (params.type === "grpc") {
|
|
yaml.push(" grpc-opts:");
|
|
yaml.push(" grpc-service-name: " + (params.serviceName || ""));
|
|
}
|
|
|
|
return yaml.join("\n");
|
|
}
|
|
|
|
// Trojan
|
|
if (link.indexOf('trojan://') === 0) {
|
|
const body = link.split('#')[0].slice(9);
|
|
const main = body.split('?')[0];
|
|
const query = body.split('?')[1] || '';
|
|
const parts = main.split('@');
|
|
const password = parts[0];
|
|
const serverParts = (parts[1] || '').split(':');
|
|
|
|
const params = {};
|
|
query.split('&').forEach((pair) => {
|
|
const p = pair.split('=');
|
|
params[p[0]] = decodeURIComponent(p[1] || '');
|
|
});
|
|
|
|
const yaml = [
|
|
'- name: "' + (remark || 'Trojan Node') + '"',
|
|
' type: trojan',
|
|
' server: ' + serverParts[0],
|
|
' port: ' + (parseInt(serverParts[1]) || 443),
|
|
' password: ' + password,
|
|
' udp: true',
|
|
' sni: ' + (params.sni || params.peer || ''),
|
|
' skip-cert-verify: true'
|
|
];
|
|
|
|
if (params.type === 'ws') {
|
|
yaml.push(' ws-opts:');
|
|
yaml.push(' path: ' + (params.path || '/'));
|
|
if (params.host) yaml.push(' headers:');
|
|
if (params.host) yaml.push(' Host: ' + params.host);
|
|
}
|
|
if (params.type === 'grpc') {
|
|
yaml.push(' grpc-opts:');
|
|
yaml.push(' grpc-service-name: ' + (params.serviceName || ''));
|
|
}
|
|
|
|
return yaml.join('\n');
|
|
}
|
|
|
|
return link;
|
|
}
|
|
|
|
function utf8Base64Decode(str) {
|
|
try {
|
|
let b64 = str.trim().replace(/-/g, '+').replace(/_/g, '/');
|
|
while (b64.length % 4 !== 0) b64 += '=';
|
|
|
|
const binary = atob(b64);
|
|
try {
|
|
if (typeof TextDecoder !== 'undefined') {
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return new TextDecoder('utf-8').decode(bytes);
|
|
}
|
|
|
|
return decodeURIComponent(binary.split('').map((c) => {
|
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
}).join(''));
|
|
} catch (e) {
|
|
return binary;
|
|
}
|
|
} catch (e) {
|
|
throw new Error('Invalid base64 content: ' + e.message);
|
|
}
|
|
}
|
|
|
|
function onInput(event) {
|
|
var target = event.target;
|
|
if (target.matches("[data-action='search-servers']")) {
|
|
state.serverSearch = target.value;
|
|
state.nodePage = 1;
|
|
render();
|
|
|
|
var input = document.querySelector("[data-action='search-servers']");
|
|
if (input) {
|
|
input.focus();
|
|
input.setSelectionRange(target.value.length, target.value.length);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function loadUplinkMetric() {
|
|
if (state.authToken) {
|
|
return;
|
|
}
|
|
const baseUrl = getMetricsBaseUrl();
|
|
if (!baseUrl) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(baseUrl + '/api/metrics/overview', {
|
|
method: 'GET',
|
|
headers: {
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
}
|
|
});
|
|
const payload = await response.json();
|
|
const network = (payload && payload.network) || {};
|
|
const tx = Number(network.tx || 0);
|
|
const total = tx;
|
|
if (total > 0) {
|
|
state.uplinkMbps = (total * 8) / 1000000;
|
|
const el = document.getElementById('nebula-hero-metric-stack');
|
|
if (el) {
|
|
el.innerHTML = renderUplinkMetricStack();
|
|
} else if (!state.authToken || !state.user) {
|
|
render();
|
|
}
|
|
}
|
|
} catch (error) {
|
|
state.uplinkMbps = null;
|
|
}
|
|
}
|
|
|
|
function onSubmit(event) {
|
|
const form = event.target;
|
|
if (!form.matches('[data-form]')) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const formType = form.getAttribute('data-form');
|
|
const data = Object.fromEntries(new FormData(form).entries());
|
|
const submitButton = form.querySelector('[type="submit"]');
|
|
|
|
if (submitButton) {
|
|
submitButton.disabled = true;
|
|
}
|
|
|
|
if (formType === 'create-ticket') {
|
|
fetchJson('/api/v1/user/ticket/save', {
|
|
method: 'POST',
|
|
body: {
|
|
subject: data.subject || '',
|
|
level: data.level || '0',
|
|
message: data.message || ''
|
|
}
|
|
}).then(() => {
|
|
showMessage('工单已创建', 'success');
|
|
state.selectedTicketId = null;
|
|
state.selectedTicket = null;
|
|
return loadDashboard(true);
|
|
}).then(render).catch((error) => {
|
|
showMessage(error.message || '工单创建失败', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === 'real-name-verification') {
|
|
fetchJson('/api/v1/user/real-name-verification/submit', {
|
|
method: 'POST',
|
|
body: {
|
|
real_name: data.real_name || '',
|
|
identity_no: data.identity_no || ''
|
|
}
|
|
}).then((response) => {
|
|
const payload = unwrap(response) || {};
|
|
const verification = payload.verification || payload;
|
|
state.realNameVerification = verification;
|
|
showMessage(
|
|
verification && verification.status === 'approved'
|
|
? '实名认证已通过。'
|
|
: '认证信息已提交,请等待审核。',
|
|
'success'
|
|
);
|
|
return loadDashboard(true);
|
|
}).then(render).catch((error) => {
|
|
showMessage(error.message || '认证提交失败', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === 'change-password') {
|
|
if (data.new_password !== data.confirm_password) {
|
|
showMessage('两次输入的新密码不一致', 'error');
|
|
if (submitButton) submitButton.disabled = false;
|
|
return;
|
|
}
|
|
fetchJson('/api/v1/user/changePassword', {
|
|
method: 'POST',
|
|
body: {
|
|
old_password: data.old_password || '',
|
|
new_password: data.new_password || ''
|
|
}
|
|
}).then(() => {
|
|
showMessage('密码已修改,建议在 IPv6 节点同步新密码', 'success');
|
|
form.reset();
|
|
}).catch((error) => {
|
|
showMessage(error.message || '密码修改失败', 'error');
|
|
}).finally(() => {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (formType === 'forget-password') {
|
|
fetchJson('/api/v1/passport/auth/forget', {
|
|
method: 'POST',
|
|
auth: false,
|
|
body: {
|
|
email: data.email,
|
|
email_code: data.email_code,
|
|
password: data.password
|
|
}
|
|
}).then(() => {
|
|
showMessage('密码已重置,请使用新密码登录', 'success');
|
|
state.mode = 'login';
|
|
render();
|
|
}).catch((error) => {
|
|
showMessage(error.message || '重置失败', 'error');
|
|
}).finally(() => {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
fetchJson(formType === 'register' ? '/api/v1/passport/auth/register' : '/api/v1/passport/auth/login', {
|
|
method: 'POST',
|
|
auth: false,
|
|
body: data
|
|
}).then((response) => {
|
|
const payload = unwrap(response);
|
|
if (!payload || !payload.auth_data) {
|
|
throw new Error('Authentication succeeded but token payload is missing');
|
|
}
|
|
saveToken(payload.auth_data);
|
|
state.authToken = getStoredToken();
|
|
|
|
showMessage(formType === 'register' ? 'Account created' : 'Signed in', 'success');
|
|
return loadDashboard(true).then(() => {
|
|
if (formType !== 'login' || !data.email || !data.password || state.ipv6AuthToken) {
|
|
return null;
|
|
}
|
|
if (!(state.ipv6Eligibility && state.ipv6Eligibility.is_active)) {
|
|
return null;
|
|
}
|
|
|
|
const ipv6Email = data.email.replace('@', '-ipv6@');
|
|
return fetchJson('/api/v1/passport/auth/login', {
|
|
method: 'POST',
|
|
auth: false,
|
|
body: Object.assign({}, data, { email: ipv6Email })
|
|
}).then((ipv6Response) => {
|
|
const ipv6Payload = unwrap(ipv6Response);
|
|
if (ipv6Payload && ipv6Payload.auth_data) {
|
|
saveIpv6Token(ipv6Payload.auth_data);
|
|
state.ipv6AuthToken = getStoredIpv6Token();
|
|
}
|
|
}).catch(() => {
|
|
return null;
|
|
}).then(() => {
|
|
return loadDashboard(true);
|
|
});
|
|
});
|
|
}).then(render).catch((error) => {
|
|
showMessage(error.message || 'Unable to continue', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
if (submitButton) {
|
|
submitButton.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleResetSecurity(actionEl, isIpv6) {
|
|
showNebulaModal({
|
|
title: '安全提醒',
|
|
content: '确定要重置订阅令牌吗?重置令牌后,原本的订阅地址将失效,你需要重新获取并配置订阅。',
|
|
confirmText: '确认重置',
|
|
onConfirm: () => {
|
|
actionEl.disabled = true;
|
|
fetchJson('/api/v1/user/resetSecurity', { method: 'GET', ipv6: !!isIpv6 }).then((response) => {
|
|
const subscribeUrl = unwrap(response);
|
|
const sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
|
if (sub && subscribeUrl) {
|
|
sub.subscribe_url = subscribeUrl;
|
|
}
|
|
showMessage((isIpv6 ? 'IPv6 ' : '') + '订阅令牌已重置', 'success');
|
|
render();
|
|
}).catch((error) => {
|
|
showMessage(error.message || '重置失败', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleRemoveSession(actionEl) {
|
|
const sessionId = actionEl.getAttribute('data-session-id');
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
|
|
actionEl.disabled = true;
|
|
fetchJson('/api/v1/user/removeActiveSession', {
|
|
method: 'POST',
|
|
body: { session_id: sessionId }
|
|
}).then(() => {
|
|
showMessage('Session revoked', 'success');
|
|
return loadDashboard(true);
|
|
}).then(render).catch((error) => {
|
|
showMessage(error.message || 'Unable to revoke session', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleRemoveOtherSessions(actionEl) {
|
|
const sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter((session) => {
|
|
return !session.is_current;
|
|
});
|
|
|
|
if (!sessions.length) {
|
|
showMessage('No other sessions to sign out.', 'success');
|
|
render();
|
|
return;
|
|
}
|
|
|
|
actionEl.disabled = true;
|
|
Promise.all(sessions.map((session) => {
|
|
return fetchJson('/api/v1/user/removeActiveSession', {
|
|
method: 'POST',
|
|
body: { session_id: session.id }
|
|
});
|
|
})).then(() => {
|
|
showMessage('Signed out all other sessions.', 'success');
|
|
return loadDashboard(true);
|
|
}).then(render).catch((error) => {
|
|
showMessage(error.message || 'Unable to sign out other sessions', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleOpenTicketDetail(actionEl) {
|
|
const ticketId = actionEl.getAttribute('data-ticket-id');
|
|
if (!ticketId) {
|
|
return;
|
|
}
|
|
actionEl.disabled = true;
|
|
fetchJson('/api/v1/user/ticket/fetch?id=' + encodeURIComponent(ticketId), { method: 'GET' }).then((response) => {
|
|
state.selectedTicketId = ticketId;
|
|
state.selectedTicket = unwrap(response);
|
|
render();
|
|
}).catch((error) => {
|
|
showMessage(error.message || '工单详情加载失败', 'error');
|
|
render();
|
|
}).finally(() => {
|
|
actionEl.disabled = false;
|
|
});
|
|
}
|
|
|
|
function handleOpenKnowledgeArticle(actionEl) {
|
|
var articleId = actionEl.getAttribute("data-article-id");
|
|
if (!articleId) {
|
|
return;
|
|
}
|
|
|
|
var article = findKnowledgeArticleById(articleId);
|
|
if (!article) {
|
|
showMessage("Knowledge article not found", "error");
|
|
return;
|
|
}
|
|
|
|
showKnowledgeArticleModal(article);
|
|
}
|
|
|
|
var currentLayout = null; // "loading", "login", "dashboard"
|
|
|
|
function render() {
|
|
if (state.loading) {
|
|
return;
|
|
}
|
|
|
|
const isLoggedIn = !!(state.authToken && state.user);
|
|
const targetLayout = isLoggedIn ? 'dashboard' : 'login';
|
|
|
|
// If layout type changed (e.g. Login -> Dashboard), do a full shell render
|
|
if (currentLayout !== targetLayout) {
|
|
currentLayout = targetLayout;
|
|
app.innerHTML = isLoggedIn ? renderDashboard() : renderAuth();
|
|
hideLoader();
|
|
return;
|
|
}
|
|
|
|
// If already in Dashboard, only update the content area and sidebar state
|
|
if (isLoggedIn) {
|
|
const usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
|
|
const totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
|
|
const remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
|
|
const percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
|
|
const stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
|
|
const overview = state.sessionOverview || {};
|
|
|
|
const contentArea = document.querySelector('.dashboard-main');
|
|
if (contentArea) {
|
|
contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview);
|
|
}
|
|
|
|
const sidebarContainer = document.querySelector('.dashboard-shell');
|
|
if (sidebarContainer) {
|
|
const sidebar = sidebarContainer.querySelector('.dashboard-sidebar');
|
|
if (sidebar) {
|
|
sidebar.innerHTML = renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, true);
|
|
}
|
|
}
|
|
} else {
|
|
// For auth page, simple re-render is fine
|
|
app.innerHTML = renderAuth();
|
|
}
|
|
|
|
hideLoader();
|
|
}
|
|
|
|
function renderAuth() {
|
|
return [
|
|
'<div class="app-shell">',
|
|
renderTopbar(false),
|
|
'<div class="auth-grid">',
|
|
'<section class="hero glass-card">',
|
|
'<div class="hero-badge-row"><span class="nebula-pill">' + escapeHtml(theme.description || "") + "</span></div>",
|
|
'<div id="nebula-hero-metric-stack" class="hero-metric-stack">' + renderUplinkMetricStack() + '</div>',
|
|
(theme.config && theme.config.slogan) ? '<p class="hero-custom-line">' + escapeHtml(theme.config.slogan) + "</p>" : "",
|
|
"</section>",
|
|
'<section class="auth-panel glass-card">',
|
|
(theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [
|
|
'<div class="form-tabs">',
|
|
tabButton("login", "登录", state.mode === "login"),
|
|
(theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "注册", state.mode === "register") : "",
|
|
tabButton("forget-password", "找回密码", state.mode === "forget-password"),
|
|
"</div>"
|
|
].join("") : [
|
|
'<div class="form-tabs">',
|
|
tabButton("login", "登录", state.mode === "login"),
|
|
tabButton("forget-password", "找回密码", state.mode === "forget-password"),
|
|
"</div>"
|
|
].join(""),
|
|
renderAuthPanelHeading(),
|
|
renderAuthForm(),
|
|
"</section>",
|
|
"</div>",
|
|
renderRecordFooter(),
|
|
"</div>"
|
|
].join("");
|
|
}
|
|
|
|
function renderDashboard() {
|
|
const usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0);
|
|
const totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0;
|
|
const remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
|
|
const percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
|
|
const stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0];
|
|
const overview = state.sessionOverview || {};
|
|
|
|
return renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview);
|
|
}
|
|
|
|
function renderTopbar(isLoggedIn) {
|
|
return [
|
|
'<header class="topbar">',
|
|
'<div class="brand">',
|
|
isLoggedIn ? '<button class="menu-toggle" data-action="toggle-sidebar" aria-label="Menu"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg></button>' : '',
|
|
renderBrandMark(),
|
|
'<div><h1>' + escapeHtml(theme.title || "Nebula") + "</h1></div>",
|
|
"</div>",
|
|
'<div class="topbar-actions">',
|
|
renderThemeToggle(),
|
|
isLoggedIn
|
|
? '<button class="btn btn-secondary" data-action="refresh-dashboard">刷新</button><button class="btn btn-ghost" data-action="logout">退出登录</button>'
|
|
: '',
|
|
"</div>",
|
|
"</header>"
|
|
].join("");
|
|
}
|
|
|
|
function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
|
|
return [
|
|
'<div class="app-shell">',
|
|
renderTopbar(true),
|
|
'<div class="sidebar-overlay' + (state.isSidebarOpen ? ' is-visible' : '') + '" data-action="close-sidebar"></div>',
|
|
'<section class="dashboard-shell">',
|
|
renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview),
|
|
'<div class="dashboard-main">',
|
|
renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview),
|
|
'</div>',
|
|
'</section>',
|
|
renderRecordFooter(),
|
|
'</div>'
|
|
].join("");
|
|
}
|
|
|
|
function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) {
|
|
if (state.currentRoute === "access-history") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSessionSection(overview.sessions || []),
|
|
renderIpSection(overview.online_ips || []),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "nodes") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSubscribeSection("span-12 section-card--overview"),
|
|
renderServerSection(state.servers || []),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "ipv6-nodes") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderIpv6SubscribeSection("span-12 section-card--overview"),
|
|
renderServerSection(state.ipv6Servers || [], true),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "tickets") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderTicketComposer(),
|
|
renderTicketSection(state.tickets || []),
|
|
renderTicketDetailSection(state.selectedTicket),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "real-name") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderRealNameVerificationPanel(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
if (state.currentRoute === "security") {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderSecuritySection(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
const html = [
|
|
'<section class="dashboard-grid dashboard-grid--overview">',
|
|
renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, 'span-12 section-card--overview'),
|
|
renderIpv6TrafficOverviewCard('span-12'),
|
|
renderAccessSnapshotCard(overview, 'span-12 section-card--overview'),
|
|
renderRealNameVerificationOverviewCard('span-12 section-card--overview'),
|
|
'</section>'
|
|
].join('');
|
|
return html;
|
|
}
|
|
|
|
function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) {
|
|
return [
|
|
'<section class="dashboard-hero">',
|
|
'<div class="hero glass-card">',
|
|
'<span class="nebula-pill">Subscription</span>',
|
|
"<h2>" + escapeHtml(theme.title || "Nebula") + "</h2>",
|
|
"<p>" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "</p>",
|
|
'<div class="dashboard-stat-grid">',
|
|
dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"),
|
|
dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"),
|
|
dashboardStat(String(overview.active_session_count || 0), "Stored sessions"),
|
|
'</div>',
|
|
'<div class="progress"><span style="width:' + percent + '%"></span></div>',
|
|
'<p class="footer-note">Used ' + formatTraffic(usedTraffic) + ' of ' + formatTraffic(totalTraffic) + ' this cycle.</p>',
|
|
'</div>',
|
|
renderSubscribeSection(),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
function renderAccessSnapshot(overview) {
|
|
return [
|
|
'<section class="dashboard-grid">',
|
|
renderAccessSnapshotCard(overview),
|
|
renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview),
|
|
'</section>'
|
|
].join("");
|
|
}
|
|
|
|
function renderAccessSnapshotCard(overview, extraClass) {
|
|
extraClass = extraClass || 'span-7';
|
|
const ipv6Overview = state.ipv6SessionOverview;
|
|
const limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? ' / ' + formatLimit(ipv6Overview.device_limit) : '');
|
|
return [
|
|
'<div class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
|
'<div class="section-head"><div><span class="tiny-pill">访问概览</span><h3>安全状态</h3></div></div>',
|
|
'<div class="kpi-row">',
|
|
kpiBox('设备数限制(IPv4/IPv6)', limitDisplay),
|
|
kpiBox('余额', formatMoney((state.user && state.user.balance) || 0, state.appConfig && state.appConfig.currency_symbol)),
|
|
kpiBox('到期时间', formatDate(state.subscribe && state.subscribe.expired_at)),
|
|
'</div>',
|
|
'</div>'
|
|
].join('');
|
|
}
|
|
|
|
function renderIpv6TrafficOverviewCard(extraClass) {
|
|
if (!state.ipv6AuthToken || !state.ipv6Subscribe) {
|
|
return [
|
|
'<article class="section-card glass-card ' + escapeHtml(extraClass || "span-6") + ' section-card--overview">',
|
|
'<div class="section-head"><div><span class="tiny-pill">IPv6 流量</span><h3>IPv6 流量概览</h3></div></div>',
|
|
'<div class="empty-state" style="padding:24px">IPv6 账号未启用或加载失败。</div>',
|
|
'</article>'
|
|
].join("");
|
|
}
|
|
|
|
var usedTraffic = (state.ipv6Subscribe.u || 0) + (state.ipv6Subscribe.d || 0);
|
|
var totalTraffic = state.ipv6Subscribe.transfer_enable || 0;
|
|
var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0);
|
|
var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0;
|
|
|
|
return renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, extraClass || "span-6 section-card--overview", "IPv6 ");
|
|
}
|
|
|
|
function renderTrafficOverviewCard(remainingTraffic, usedTraffic, totalTraffic, percent, extraClass, prefix) {
|
|
extraClass = extraClass || "span-5";
|
|
prefix = prefix || "";
|
|
return [
|
|
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
|
'<div class="section-head"><div><span class="tiny-pill">流量</span><h3>' + prefix + '用户流量概览</h3></div></div>',
|
|
'<div class="kpi-row">',
|
|
kpiBox("剩余", formatTraffic(remainingTraffic)),
|
|
kpiBox("已用", formatTraffic(usedTraffic)),
|
|
kpiBox("总量", formatTraffic(totalTraffic)),
|
|
kpiBox("使用率", String(percent) + "%"),
|
|
'</div>',
|
|
'<div class="progress"><span style="width:' + percent + '%"></span></div>',
|
|
'</article>'
|
|
].join("");
|
|
}
|
|
|
|
function renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, innerOnly) {
|
|
let content = [
|
|
'<div class="sidebar-nav">',
|
|
sidebarLink('overview', '总览', '查看关键概览'),
|
|
sidebarLink('access-history', '访问记录', '查看会话与当前 IP'),
|
|
sidebarLink('nodes', '节点', '查看服务节点'),
|
|
sidebarLink('ipv6-nodes', 'IPv6 节点', '查看 IPv6 服务节点'),
|
|
sidebarLink('notices', '知识库', '查看最新内容'),
|
|
sidebarLink('tickets', '工单', '查看与创建工单'),
|
|
sidebarLink('security', '账号安全', '修改密码与安全设置'),
|
|
'</div>'
|
|
].join('');
|
|
content = content.replace(/<button class="sidebar-link[^"]*" data-action="navigate" data-route="notices"[\s\S]*?<\/button>/, '');
|
|
content = content.replace(
|
|
'</div>',
|
|
sidebarLink('real-name', '\u5b9e\u540d\u8ba4\u8bc1', '\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001') + '</div>'
|
|
);
|
|
|
|
if (innerOnly) return content;
|
|
|
|
return [
|
|
'<aside class="dashboard-sidebar glass-card' + (state.isSidebarOpen ? ' is-open' : '') + '">',
|
|
content,
|
|
'</aside>'
|
|
].join('');
|
|
}
|
|
|
|
function renderAuthForm() {
|
|
var isRegister = state.mode === "register";
|
|
var isForget = state.mode === "forget-password";
|
|
|
|
if (isForget) {
|
|
return [
|
|
'<form class="stack" data-form="forget-password" style="gap:12px">',
|
|
'<div class="field"><input name="email" type="email" placeholder="邮箱地址" required /></div>',
|
|
'<div class="field">',
|
|
'<div class="input-with-button">',
|
|
'<input name="email_code" type="text" placeholder="邮箱验证码" required />',
|
|
'<button type="button" class="btn btn-secondary btn-tiny send-code-btn" data-action="send-reset-code">发送验证码</button>',
|
|
'</div></div>',
|
|
'<div class="field"><input name="password" type="password" placeholder="设置新密码" minlength="8" required /></div>',
|
|
'<div class="form-actions" style="margin-top:10px">',
|
|
'<button class="btn btn-primary" type="submit">立即重置</button>',
|
|
"</div>",
|
|
"</form>"
|
|
].join("");
|
|
}
|
|
|
|
return [
|
|
'<form class="stack" data-form="' + (isRegister ? "register" : "login") + '">',
|
|
'<div class="field"><label>Email</label><input name="email" type="email" placeholder="name@example.com" required /></div>',
|
|
'<div class="field"><label>Password</label><input name="password" type="password" placeholder="Minimum 8 characters" minlength="8" required /></div>',
|
|
'<div class="form-actions">',
|
|
'<button class="btn btn-primary" type="submit">' + (isRegister ? "创建账号" : "立即登录") + "</button>",
|
|
"</div>",
|
|
"</form>"
|
|
].join("");
|
|
}
|
|
|
|
function renderIpv6SubscribeSection(extraClass) {
|
|
return renderSubscribeSection(extraClass, true);
|
|
}
|
|
|
|
function renderSubscribeSection(extraClass, isIpv6) {
|
|
extraClass = extraClass || 'span-7';
|
|
const sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
|
const user = isIpv6 ? state.ipv6User : state.user;
|
|
const ipv6Eligibility = state.ipv6Eligibility || {};
|
|
const prefix = isIpv6 ? 'IPv6 ' : '';
|
|
const copyAction = isIpv6 ? 'copy-ipv6-subscribe' : 'copy-subscribe';
|
|
const resetAction = isIpv6 ? 'reset-ipv6-security' : 'reset-security';
|
|
|
|
if (isIpv6 && !state.ipv6AuthToken) {
|
|
// IPv6 not enabled - show guidance to submit a ticket
|
|
} else if (isIpv6 && !sub) {
|
|
return '';
|
|
}
|
|
|
|
let ipv6FooterHtml = '';
|
|
if (isIpv6 && !state.ipv6AuthToken) {
|
|
const statusText = ipv6Eligibility.status_label || '未开通';
|
|
let guidanceText = 'IPv6 订阅需要通过工单由管理员开通,请提交工单申请。';
|
|
if (ipv6Eligibility.is_active) {
|
|
guidanceText = '您的 IPv6 已开通,如有问题请联系管理员。';
|
|
}
|
|
ipv6FooterHtml = '<div class="card-footer" style="margin-top:16px;border-top:1px solid var(--border-color);padding-top:16px">' +
|
|
'<div class="empty-state" style="padding:0;text-align:left">' +
|
|
'<strong>' + escapeHtml(statusText) + '</strong> — ' + escapeHtml(guidanceText) +
|
|
'</div></div>';
|
|
}
|
|
|
|
const html = [
|
|
'<article class="section-card glass-card ' + escapeHtml(extraClass) + '">',
|
|
'<div class="section-head"><div><span class="tiny-pill">' + prefix + '订阅</span><h3>连接工具</h3></div><div class="toolbar">',
|
|
'<button class="btn btn-secondary" data-action="' + copyAction + '">复制链接</button>',
|
|
'</div></div>',
|
|
'<div class="kpi-row">',
|
|
!isIpv6 ? kpiBox('套餐', escapeHtml((sub && sub.plan && sub.plan.name) || '暂无套餐')) : '',
|
|
kpiBox('限速', formatSpeed(sub && sub.speed_limit)),
|
|
kpiBox('重置日', String((sub && sub.reset_day) || '-')),
|
|
!isIpv6 ? kpiBox('邮箱', '<span style="font-family:var(--font-mono)">' + escapeHtml((user && user.email) || '-') + '</span>') : '',
|
|
'</div>',
|
|
ipv6FooterHtml,
|
|
'</article>'
|
|
].join('');
|
|
return html;
|
|
}
|
|
|
|
function renderOpsSection(stats, overview) {
|
|
return [
|
|
'<article class="section-card glass-card span-5">',
|
|
'<div class="section-head"><div><span class="tiny-pill">运营</span><h3>当前工作台</h3></div></div>',
|
|
'<div class="kpi-row">',
|
|
kpiBox("待处理订单", String(stats[0] || 0)),
|
|
kpiBox("工单数量", String(stats[1] || 0)),
|
|
kpiBox("邀请用户", String(stats[2] || 0)),
|
|
kpiBox("在线设备", String(overview.online_device_count || 0)),
|
|
"</div>",
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderIpSection(ips) {
|
|
return [
|
|
'<article class="section-card glass-card span-6">',
|
|
'<div class="section-head"><div><span class="tiny-pill">当前 IP</span><h3>在线入口</h3></div></div>',
|
|
renderIpList(ips),
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderSessionSection(sessions) {
|
|
return [
|
|
'<article class="section-card glass-card span-6">',
|
|
'<div class="section-head"><div><span class="tiny-pill">会话</span><h3>访问记录</h3></div><div class="toolbar"><button class="btn btn-ghost" data-action="remove-other-sessions">退出其他会话</button></div></div>',
|
|
renderSessions(sessions),
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderServerSection(servers, isIpv6) {
|
|
return [
|
|
'<article class="section-card glass-card span-12 section-card--nodes">',
|
|
'<div class="section-head">',
|
|
'<div><span class="tiny-pill">' + (isIpv6 ? "IPv6 " : "") + '节点</span><h3>' + (isIpv6 ? "IPv6 " : "") + '服务节点' + '</h3></div>',
|
|
'<div class="toolbar">',
|
|
'<input type="text" class="node-search-input" placeholder="搜索节点..." data-action="search-servers" value="' + escapeHtml(state.serverSearch || "") + '" />',
|
|
'</div>',
|
|
'</div>',
|
|
renderServers(servers),
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderKnowledgeSection(articles) {
|
|
if (!articles.length) {
|
|
return [
|
|
'<article class="section-card glass-card span-6">',
|
|
'<div class="section-head"><div><span class="tiny-pill">知识库</span><h3>知识内容</h3></div></div>',
|
|
'<div class="empty-state">暂时没有内容。</div>',
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
// Group articles by category if possibly present, or just list them
|
|
return [
|
|
'<article class="section-card glass-card span-12">',
|
|
'<div class="section-head"><div><span class="tiny-pill">知识库</span><h3>全部文章</h3></div></div>',
|
|
'<div class="notice-list">' + articles.map(function (article) {
|
|
return [
|
|
'<div class="notice-item">',
|
|
'<div class="notice-copy">',
|
|
"<strong>" + escapeHtml(article.title || "未命名文章") + "</strong>",
|
|
'<span class="notice-meta">' + escapeHtml(truncate(article.body || article.content || "", 160)) + "</span>",
|
|
"</div>",
|
|
article.category ? '<span class="tiny-pill">' + escapeHtml(article.category) + '</span>' : formatDate(article.updated_at || article.created_at),
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + "</div>",
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderTicketSection(tickets) {
|
|
return [
|
|
'<article class="section-card glass-card span-7">',
|
|
'<div class="section-head"><div><span class="tiny-pill">工单</span><h3>工单列表</h3></div></div>',
|
|
renderTickets(tickets),
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderTicketComposer() {
|
|
return [
|
|
'<article class="section-card glass-card span-5">',
|
|
'<div class="section-head"><div><span class="tiny-pill">新建</span><h3>创建工单</h3></div></div>',
|
|
'<form class="stack" data-form="create-ticket">',
|
|
'<div class="field"><label>主题</label><input name="subject" type="text" placeholder="请输入工单主题" required /></div>',
|
|
'<div class="field"><label>等级</label><select name="level" class="ticket-select"><option value="0">低</option><option value="1">中</option><option value="2">高</option></select></div>',
|
|
'<div class="field"><label>内容</label><textarea name="message" rows="6" placeholder="请描述你遇到的问题" required></textarea></div>',
|
|
'<div class="form-actions"><button class="btn btn-primary" type="submit">提交工单</button></div>',
|
|
'</form>',
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
function renderTicketDetailSection(ticket) {
|
|
if (!ticket) {
|
|
return "";
|
|
}
|
|
return [
|
|
'<article class="section-card glass-card span-12">',
|
|
'<div class="section-head"><div><span class="tiny-pill">详情</span><h3>' + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + '</h3></div><div class="toolbar"><button class="btn btn-ghost" data-action="close-ticket-detail">关闭详情</button></div></div>',
|
|
'<div class="kpi-row">',
|
|
kpiBox("状态", renderTicketStatus(ticket.status)),
|
|
kpiBox("等级", getTicketLevelLabel(ticket.level)),
|
|
kpiBox("创建时间", formatDate(ticket.created_at)),
|
|
kpiBox("更新时间", formatDate(ticket.updated_at)),
|
|
'</div>',
|
|
renderTicketMessages(ticket.message || []),
|
|
"</article>"
|
|
].join("");
|
|
}
|
|
|
|
|
|
function renderIpList(ips) {
|
|
var list = ips.slice();
|
|
if (!list.length) {
|
|
return '<div class="empty-state">当前还没有检测到在线 IP 记录。</div>';
|
|
}
|
|
return '<div class="ip-list">' + list.map(function (ip) {
|
|
return '<div class="ip-item"><span class="meta-label">在线设备 IP</span><code>' + escapeHtml(ip) + '</code></div>';
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function renderSessions(sessions) {
|
|
if (!sessions.length) {
|
|
return '<div class="empty-state">当前没有会话记录。</div>';
|
|
}
|
|
return '<div class="session-list">' + sessions.map(function (session) {
|
|
var tag = session.is_current ? '<span class="status-chip online">当前</span>' : '<span class="status-chip">已保存</span>';
|
|
var revoke = session.is_current ? "" : '<button class="btn btn-ghost" data-action="remove-session" data-session-id="' + escapeHtml(String(session.id)) + '">撤销</button>';
|
|
return [
|
|
'<div class="session-item">',
|
|
'<div class="session-copy">',
|
|
"<strong>" + escapeHtml(session.name || ("Session #" + session.id)) + "</strong>",
|
|
'<span class="session-meta">创建于 ' + formatDate(session.created_at) + " · 最近使用 " + formatDate(session.last_used_at) + (session.ip ? " · IP " + escapeHtml(session.ip) : "") + "</span>",
|
|
"</div>",
|
|
'<div class="inline-actions">' + tag + revoke + "</div>",
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function renderServers(servers) {
|
|
if (!servers.length) {
|
|
return '<div class="empty-state">当前没有可用节点。</div>';
|
|
}
|
|
var sortedServers = servers.slice().sort(compareServers);
|
|
|
|
var searchTerm = (state.serverSearch || "").toLowerCase();
|
|
var filteredServers = sortedServers;
|
|
if (searchTerm) {
|
|
filteredServers = sortedServers.filter(function (server) {
|
|
var nameMatch = (server.name || "").toLowerCase().indexOf(searchTerm) !== -1;
|
|
var tags = normalizeServerTags(server.tags);
|
|
var tagMatch = tags.some(function (tag) {
|
|
return String(tag).toLowerCase().indexOf(searchTerm) !== -1;
|
|
});
|
|
return nameMatch || tagMatch;
|
|
});
|
|
}
|
|
|
|
if (!filteredServers.length && servers.length > 0) {
|
|
return '<div class="empty-state">没有匹配的节点。</div>';
|
|
}
|
|
|
|
var pageSize = getNodePageSize();
|
|
var totalPages = Math.max(1, Math.ceil(filteredServers.length / pageSize));
|
|
if (state.nodePage > totalPages) {
|
|
state.nodePage = totalPages;
|
|
}
|
|
if (state.nodePage < 1) {
|
|
state.nodePage = 1;
|
|
}
|
|
var startIndex = (state.nodePage - 1) * pageSize;
|
|
var pagedServers = filteredServers.slice(startIndex, startIndex + pageSize);
|
|
|
|
return '<div class="node-list-wrap"><div class="node-list">' + pagedServers.map(function (server) {
|
|
var tags = normalizeServerTags(server.tags);
|
|
return [
|
|
'<div class="node-item">',
|
|
'<div class="node-head">',
|
|
'<div class="node-copy">',
|
|
"<strong>" + escapeHtml(server.name || "未命名节点") + "</strong>",
|
|
'<span class="node-meta">' + escapeHtml(server.type || "node") + "</span>",
|
|
"</div>",
|
|
'<button class="node-action-btn" data-action="copy-node-info" data-node-id="' + server.id + '">查看配置</button>',
|
|
"</div>",
|
|
'<div class="node-specs">',
|
|
nodeSpec("倍率", escapeHtml(String(server.rate || 1)) + "x"),
|
|
nodeSpec("状态", '<span class="status-chip ' + (server.is_online ? "online" : "offline") + '">' + (server.is_online ? "在线" : "离线") + "</span>"),
|
|
"</div>",
|
|
tags.length ? '<div class="node-tags">' + tags.map(function (tag) {
|
|
return '<span class="tiny-pill">' + escapeHtml(String(tag)) + "</span>";
|
|
}).join("") + "</div>" : "",
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + '</div>' + renderNodePagination(totalPages) + "</div>";
|
|
}
|
|
|
|
function renderNotices(articles) {
|
|
if (!articles.length) {
|
|
return '<div class="empty-state">暂时没有内容。</div>';
|
|
}
|
|
return '<div class="notice-list">' + articles.map(function (article) {
|
|
return [
|
|
'<div class="notice-item">',
|
|
'<div class="notice-copy">',
|
|
"<strong>" + escapeHtml(article.title || "公告") + "</strong>",
|
|
'<span class="notice-meta">' + escapeHtml(truncate(article.body || article.content || "", 160)) + "</span>",
|
|
"</div>",
|
|
'<span class="tiny-pill">' + formatDate(article.updated_at || article.created_at) + "</span>",
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function renderTickets(tickets) {
|
|
if (!tickets.length) {
|
|
return '<div class="empty-state">当前没有工单记录。</div>';
|
|
}
|
|
return '<div class="notice-list">' + tickets.map(function (ticket) {
|
|
return [
|
|
'<div class="notice-item">',
|
|
'<div class="notice-copy">',
|
|
"<strong>" + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + "</strong>",
|
|
'<span class="notice-meta">等级 ' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' · 更新时间 ' + formatDate(ticket.updated_at || ticket.created_at) + "</span>",
|
|
"</div>",
|
|
'<div class="inline-actions">' + renderTicketStatus(ticket.status) + '<button class="btn btn-ghost" data-action="open-ticket-detail" data-ticket-id="' + escapeHtml(String(ticket.id)) + '">查看详情</button></div>',
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function renderTicketMessages(messages) {
|
|
if (!messages.length) {
|
|
return '<div class="empty-state">当前工单还没有更多消息。</div>';
|
|
}
|
|
return '<div class="session-list">' + messages.map(function (message) {
|
|
return [
|
|
'<div class="session-item">',
|
|
'<div class="session-copy">',
|
|
'<strong>' + (message.is_me ? "我" : "客服") + "</strong>",
|
|
'<span class="session-meta">' + formatDate(message.created_at) + "</span>",
|
|
'<span class="notice-meta">' + escapeHtml(message.message || "") + "</span>",
|
|
"</div>",
|
|
"</div>"
|
|
].join("");
|
|
}).join("") + "</div>";
|
|
}
|
|
|
|
function renderTicketStatus(status) {
|
|
const isOpen = Number(status) === 0;
|
|
return '<span class="status-chip ' + (isOpen ? 'online' : 'offline') + '">' + (isOpen ? '处理中' : '已关闭') + '</span>';
|
|
}
|
|
|
|
function getTicketLevelLabel(level) {
|
|
if (String(level) === "2") {
|
|
return "高";
|
|
}
|
|
if (String(level) === "1") {
|
|
return "中";
|
|
}
|
|
return "低";
|
|
}
|
|
|
|
function metricBox(value, label) {
|
|
return '<div class="metric-box"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
|
|
}
|
|
|
|
function nodeSpec(label, value) {
|
|
return '<div class="node-spec"><span class="meta-label">' + escapeHtml(label) + '</span><strong>' + value + "</strong></div>";
|
|
}
|
|
|
|
function renderNodePagination(totalPages) {
|
|
if (totalPages <= 1) {
|
|
return "";
|
|
}
|
|
return [
|
|
'<div class="node-pagination">',
|
|
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.max(1, state.nodePage - 1)) + '"' + (state.nodePage <= 1 ? " disabled" : "") + '>上一页</button>',
|
|
'<span class="tiny-pill">第 ' + String(state.nodePage) + " / " + String(totalPages) + " 页</span>",
|
|
'<button class="btn btn-ghost" data-action="change-node-page" data-page="' + String(Math.min(totalPages, state.nodePage + 1)) + '"' + (state.nodePage >= totalPages ? " disabled" : "") + '>下一页</button>',
|
|
"</div>"
|
|
].join("");
|
|
}
|
|
|
|
function getNodePageSize() {
|
|
const width = window.innerWidth || document.documentElement.clientWidth || 1280;
|
|
if (width <= 860) {
|
|
return 2;
|
|
}
|
|
if (width <= 1180) {
|
|
return 4;
|
|
}
|
|
return 6;
|
|
}
|
|
|
|
function compareServers(left, right) {
|
|
var leftOnline = left && left.is_online ? 1 : 0;
|
|
var rightOnline = right && right.is_online ? 1 : 0;
|
|
if (leftOnline !== rightOnline) {
|
|
return rightOnline - leftOnline;
|
|
}
|
|
|
|
var leftRate = toSortableNumber(left && left.rate, Number.POSITIVE_INFINITY);
|
|
var rightRate = toSortableNumber(right && right.rate, Number.POSITIVE_INFINITY);
|
|
if (leftRate !== rightRate) {
|
|
return leftRate - rightRate;
|
|
}
|
|
|
|
var leftCheckedAt = toTimestamp(left && left.last_check_at) || 0;
|
|
var rightCheckedAt = toTimestamp(right && right.last_check_at) || 0;
|
|
if (leftCheckedAt !== rightCheckedAt) {
|
|
return rightCheckedAt - leftCheckedAt;
|
|
}
|
|
|
|
return String((left && left.name) || "").localeCompare(String((right && right.name) || ""));
|
|
}
|
|
|
|
function normalizeServerTags(tags) {
|
|
if (!tags) {
|
|
return [];
|
|
}
|
|
if (Array.isArray(tags)) {
|
|
return tags.filter(Boolean);
|
|
}
|
|
if (typeof tags === 'string') {
|
|
let normalized = tags.trim();
|
|
if (!normalized || normalized === '[]' || normalized === 'null' || normalized === '""') {
|
|
return [];
|
|
}
|
|
if ((normalized.charAt(0) === '[' && normalized.charAt(normalized.length - 1) === ']') ||
|
|
(normalized.charAt(0) === '{' && normalized.charAt(normalized.length - 1) === '}')) {
|
|
try {
|
|
const parsed = JSON.parse(normalized);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((tag) => {
|
|
return String(tag).trim();
|
|
}).filter(Boolean);
|
|
}
|
|
} catch (error) {
|
|
normalized = normalized.replace(/^\[/, '').replace(/\]$/, '');
|
|
}
|
|
}
|
|
return normalized.split(/[|,\/]/).map((tag) => {
|
|
return tag.trim().replace(/^["'\[]+|["'\]]+$/g, '');
|
|
}).filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function toSortableNumber(value, fallback) {
|
|
var numeric = Number(value);
|
|
return Number.isFinite(numeric) ? numeric : fallback;
|
|
}
|
|
|
|
function buildSessionOverview(list, extra, isIpv6) {
|
|
const ipMap = {};
|
|
const deviceMap = {};
|
|
let lastOnlineAt = null;
|
|
|
|
const normalizedSessions = list.map((session) => {
|
|
const lastUsedAt = toTimestamp(session.last_used_at);
|
|
const createdAt = toTimestamp(session.created_at);
|
|
const expiresAt = toTimestamp(session.expires_at);
|
|
const sessionName = session.name || session.device_name || session.user_agent || ('Session #' + (session.id || ''));
|
|
const sessionIp = firstNonEmpty([
|
|
session.ip,
|
|
session.ip_address,
|
|
session.last_used_ip,
|
|
session.login_ip,
|
|
session.current_ip
|
|
]);
|
|
const deviceKey = firstNonEmpty([
|
|
session.device_name,
|
|
session.user_agent,
|
|
session.client_type,
|
|
session.os,
|
|
sessionName
|
|
]) || ('device-' + Math.random().toString(36).slice(2));
|
|
|
|
if (sessionIp) {
|
|
ipMap[sessionIp] = true;
|
|
}
|
|
if (deviceKey) {
|
|
deviceMap[deviceKey] = true;
|
|
}
|
|
if (lastUsedAt && (!lastOnlineAt || lastUsedAt > lastOnlineAt)) {
|
|
lastOnlineAt = lastUsedAt;
|
|
}
|
|
|
|
return {
|
|
id: session.id,
|
|
name: sessionName,
|
|
abilities: session.abilities,
|
|
ip: sessionIp,
|
|
user_agent: session.user_agent || '',
|
|
last_used_at: lastUsedAt,
|
|
created_at: createdAt,
|
|
expires_at: expiresAt,
|
|
is_current: Boolean(session.is_current || session.current || session.is_login || session.this_device)
|
|
};
|
|
});
|
|
|
|
const sub = isIpv6 ? state.ipv6Subscribe : state.subscribe;
|
|
|
|
return {
|
|
online_ip_count: Object.keys(ipMap).length,
|
|
online_ips: Object.keys(ipMap),
|
|
online_device_count: Object.keys(deviceMap).length,
|
|
device_limit: extra.device_limit || (sub ? (sub.device_limit || (sub.plan && sub.plan.device_limit) || null) : null),
|
|
last_online_at: toTimestamp(extra.last_online_at) || lastOnlineAt,
|
|
active_session_count: normalizedSessions.length,
|
|
sessions: normalizedSessions
|
|
};
|
|
}
|
|
|
|
function dashboardStat(value, label) {
|
|
return '<div class="dashboard-stat"><span class="value">' + escapeHtml(value) + '</span><span class="label">' + escapeHtml(label) + "</span></div>";
|
|
}
|
|
|
|
function kpiBox(label, value) {
|
|
return '<div class="kpi-box"><span class="kpi-label">' + label + '</span><span class="kpi-value">' + value + "</span></div>";
|
|
}
|
|
|
|
function sidebarLink(route, title, copy) {
|
|
return '<button class="sidebar-link' + (state.currentRoute === route ? ' is-active' : '') + '" data-action="navigate" data-route="' + escapeHtml(route) + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(copy) + "</span></button>";
|
|
}
|
|
|
|
function renderBrandMark() {
|
|
const logoUrl = getThemeLogo();
|
|
if (logoUrl) {
|
|
return '<span class="brand-mark brand-mark--image"><img src="' + escapeHtml(logoUrl) + '" alt="' + escapeHtml(theme.title || 'Nebula') + ' logo" /></span>';
|
|
}
|
|
return '<span class="brand-mark" aria-hidden="true"></span>';
|
|
}
|
|
|
|
function renderThemeToggle() {
|
|
const isLight = state.themeMode === 'light';
|
|
return [
|
|
'<button class="theme-switch' + (isLight ? ' is-light' : ' is-dark') + '" data-action="toggle-theme-mode" type="button" aria-label="Toggle Theme">',
|
|
'<span class="theme-switch__track">',
|
|
'<span class="theme-switch__icon theme-switch__icon--moon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path></svg></span>',
|
|
'<span class="theme-switch__icon theme-switch__icon--sun"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg></span>',
|
|
'<span class="theme-switch__thumb"></span>',
|
|
'</span>',
|
|
'</button>'
|
|
].join('');
|
|
}
|
|
|
|
function renderRecordFooter() {
|
|
const records = [];
|
|
if (theme.config && theme.config.icpNo) {
|
|
records.push('<span>' + escapeHtml(theme.config.icpNo) + '</span>');
|
|
}
|
|
if (theme.config && theme.config.psbNo) {
|
|
records.push('<span>' + escapeHtml(theme.config.psbNo) + '</span>');
|
|
}
|
|
if (!records.length) {
|
|
return '';
|
|
}
|
|
return '<footer class="record-footer">' + records.join('<span class="record-divider">|</span>') + '</footer>';
|
|
}
|
|
|
|
function renderAuthPanelHeading() {
|
|
return [
|
|
'<div class="auth-panel-heading">',
|
|
'<p class="auth-panel-eyebrow">Welcome To</p>',
|
|
'<h2>' + escapeHtml(getAuthPanelTitle()) + '</h2>',
|
|
'</div>'
|
|
].join('');
|
|
}
|
|
|
|
function getAuthPanelTitle() {
|
|
if (state.mode === 'register') {
|
|
return (theme.config && theme.config.registerTitle) || 'Create your access.';
|
|
}
|
|
return (theme.config && theme.config.welcomeTarget) || (theme && theme.title) || 'Your Space';
|
|
}
|
|
|
|
function onRouteChange() {
|
|
state.currentRoute = getCurrentRoute();
|
|
if (state.authToken && state.user) {
|
|
render();
|
|
}
|
|
}
|
|
|
|
function setCurrentRoute(route) {
|
|
const nextRoute = isValidRoute(route) ? route : 'overview';
|
|
state.currentRoute = nextRoute;
|
|
window.location.hash = '#/' + nextRoute;
|
|
render();
|
|
}
|
|
|
|
function setNodePage(page) {
|
|
state.nodePage = page > 0 ? page : 1;
|
|
render();
|
|
}
|
|
|
|
function toggleThemeMode() {
|
|
state.themePreference = state.themeMode === "light" ? "dark" : "light";
|
|
persistThemePreference(state.themePreference);
|
|
applyThemeMode();
|
|
render();
|
|
}
|
|
|
|
function getCurrentRoute() {
|
|
const hash = String(window.location.hash || '');
|
|
const route = hash.indexOf('#/') === 0 ? hash.slice(2) : '';
|
|
return isValidRoute(route) ? route : 'overview';
|
|
}
|
|
|
|
function getStoredThemePreference() {
|
|
let stored = '';
|
|
try {
|
|
stored = window.localStorage.getItem('nebula_theme_preference') || '';
|
|
} catch (error) {
|
|
stored = '';
|
|
}
|
|
if (stored === 'light' || stored === 'dark' || stored === 'system') {
|
|
return stored;
|
|
}
|
|
return getDefaultThemePreference();
|
|
}
|
|
|
|
function persistThemePreference(mode) {
|
|
try {
|
|
window.localStorage.setItem("nebula_theme_preference", mode);
|
|
} catch (error) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
function applyThemeMode() {
|
|
state.themeMode = resolveThemeMode(state.themePreference);
|
|
document.documentElement.dataset.themeMode = state.themeMode;
|
|
document.documentElement.dataset.themePreference = state.themePreference;
|
|
updateThemeColorMeta();
|
|
}
|
|
|
|
function getThemeToggleLabel() {
|
|
return state.themeMode === "light" ? "切换至黑夜模式" : "切换至白天模式";
|
|
}
|
|
|
|
function getRealNameVerificationState() {
|
|
return state.realNameVerification || {
|
|
enabled: false,
|
|
status: "unavailable",
|
|
status_label: "Unavailable",
|
|
can_submit: false,
|
|
real_name: "",
|
|
identity_no_masked: "",
|
|
reject_reason: "",
|
|
notice: "",
|
|
submitted_at: null,
|
|
reviewed_at: null
|
|
};
|
|
}
|
|
|
|
function getRealNameStatusTone(status) {
|
|
if (status === "approved") {
|
|
return "online";
|
|
}
|
|
if (status === "rejected") {
|
|
return "offline";
|
|
}
|
|
if (status === "pending") {
|
|
return "pending";
|
|
}
|
|
return "neutral";
|
|
}
|
|
|
|
function renderRealNameStatusChip(status, label) {
|
|
return '<span class="status-chip ' + getRealNameStatusTone(status) + '">' + escapeHtml(label || "未知状态") + '</span>';
|
|
}
|
|
|
|
function renderRealNameVerificationOverviewCard(extraClass) {
|
|
const verification = getRealNameVerificationState();
|
|
if (!verification.enabled) {
|
|
return '';
|
|
}
|
|
|
|
const submitted = verification.submitted_at ? formatDate(verification.submitted_at) : '-';
|
|
const reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : '-';
|
|
|
|
const html = [
|
|
'<article class="section-card glass-card ' + escapeHtml(extraClass || 'span-12') + '">',
|
|
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
|
|
'<div class="kpi-row">',
|
|
kpiBox('认证状态', escapeHtml(verification.status_label || '未认证')),
|
|
kpiBox('真实姓名', escapeHtml(verification.real_name || '-')),
|
|
kpiBox('证件号码', escapeHtml(verification.identity_no_masked || '-')),
|
|
'</div>',
|
|
verification.reviewed_at ? '<div class="footer-note" style="margin-top:16px;">审核于 ' + escapeHtml(reviewed) + '</div>' : '',
|
|
verification.reject_reason ? '<div class="real-name-alert real-name-alert--rejected">审核备注: ' + escapeHtml(verification.reject_reason) + '</div>' : '',
|
|
'</article>'
|
|
].join('');
|
|
}
|
|
|
|
function renderRealNameVerificationPanel() {
|
|
var verification = getRealNameVerificationState();
|
|
if (!verification.enabled) {
|
|
return "";
|
|
}
|
|
|
|
var detailBlocks = [
|
|
'<div class="kpi-row">',
|
|
'<div class="kpi-box"><span class="kpi-label">当前状态</span><span class="kpi-value">' + renderRealNameStatusChip(verification.status, verification.status_label) + '</span></div>',
|
|
'<div class="kpi-box"><span class="kpi-label">真实姓名</span><span class="kpi-value">' + escapeHtml(verification.real_name || "-") + '</span></div>',
|
|
'<div class="kpi-box"><span class="kpi-label">身份证号</span><span class="kpi-value">' + escapeHtml(verification.identity_no_masked || "-") + '</span></div>',
|
|
'<div class="kpi-box"><span class="kpi-label">提交时间</span><span class="kpi-value">' + escapeHtml(verification.submitted_at ? formatDate(verification.submitted_at) : "-") + '</span></div>',
|
|
'</div>'
|
|
];
|
|
|
|
if (verification.reviewed_at) {
|
|
detailBlocks.push('<div class="footer-note" style="margin-top:16px;">审核于 ' + escapeHtml(formatDate(verification.reviewed_at)) + '</div>');
|
|
}
|
|
|
|
if (verification.reject_reason) {
|
|
detailBlocks.push('<div class="real-name-alert real-name-alert--rejected">驳回原因: ' + escapeHtml(verification.reject_reason) + '</div>');
|
|
} else if (verification.notice) {
|
|
detailBlocks.push('<div class="real-name-alert">提示: ' + escapeHtml(verification.notice) + '</div>');
|
|
}
|
|
|
|
if (!verification.can_submit) {
|
|
return [
|
|
'<article class="section-card glass-card span-12">',
|
|
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>认证信息</h3></div></div>',
|
|
detailBlocks.join(""),
|
|
'</article>'
|
|
].join("");
|
|
}
|
|
|
|
return [
|
|
'<article class="section-card glass-card span-12">',
|
|
'<div class="section-head"><div><span class="tiny-pill">实名认证</span><h3>提交认证</h3></div></div>',
|
|
detailBlocks.join(""),
|
|
'<form class="stack" data-form="real-name-verification" style="margin-top:24px; max-width: 560px;">',
|
|
'<div class="field"><label>真实姓名</label><input name="real_name" type="text" placeholder="请输入您的法律姓名" value="' + escapeHtml(verification.real_name || "") + '" required /></div>',
|
|
'<div class="field"><label>身份证号</label><input name="identity_no" type="text" placeholder="请输入 18 位身份证号码" maxlength="18" required /></div>',
|
|
'<div class="field-help">您的身份证号码将以加密形式存储,并在控制台中脱敏显示。</div>',
|
|
'<div class="form-actions" style="margin-top:12px;"><button class="btn btn-primary" type="submit">' + (verification.status === "rejected" ? "重新提交认证" : "提交认证") + '</button></div>',
|
|
'</form>',
|
|
'</article>'
|
|
].join("");
|
|
}
|
|
|
|
function renderSecuritySection() {
|
|
return [
|
|
'<article class="section-card glass-card span-12">',
|
|
'<div class="section-head"><div><span class="tiny-pill">账户设置</span><h3>修改密码</h3></div></div>',
|
|
'<form class="stack" data-form="change-password" style="margin-top:24px; max-width: 480px;">',
|
|
'<div class="field"><label>旧密码</label><input name="old_password" type="password" placeholder="请输入当前使用的密码" required /></div>',
|
|
'<div class="field"><label>新密码</label><input name="new_password" type="password" placeholder="请输入 8 位以上新密码" minlength="8" required /></div>',
|
|
'<div class="field"><label>确认新密码</label><input name="confirm_password" type="password" placeholder="请再次输入新密码" minlength="8" required /></div>',
|
|
'<div class="form-actions" style="margin-top:12px;">',
|
|
'<button class="btn btn-primary" type="submit">立即修改</button>',
|
|
'</div>',
|
|
'</form>',
|
|
"</article>",
|
|
'<article class="section-card glass-card span-12" style="margin-top:24px;">',
|
|
'<div class="section-head"><div><span class="tiny-pill">安全操作</span><h3>订阅令牌管理</h3></div></div>',
|
|
'<div class="kpi-row" style="margin-top:20px;">',
|
|
'<div class="kpi-box"><span class="kpi-label">IPv4 订阅</span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-security">重置令牌</button></div></div>',
|
|
'<div class="kpi-box"><span class="kpi-label">IPv6 订阅</span><div class="toolbar" style="margin-top:12px;"><button class="btn btn-secondary" data-action="reset-ipv6-security">重置令牌</button></div></div>',
|
|
'</div>',
|
|
'<div class="footer-note" style="margin-top:16px;">提示:重置令牌后原本的订阅链接将立即失效,您需要重新在连接工具卡片中复制新链接。</div>',
|
|
'</article>'
|
|
].join("");
|
|
}
|
|
|
|
function getThemeLogo() {
|
|
const config = theme.config || {};
|
|
if (state.themeMode === 'light') {
|
|
return config.lightLogoUrl || config.darkLogoUrl || theme.logo || '';
|
|
}
|
|
return config.darkLogoUrl || theme.logo || config.lightLogoUrl || '';
|
|
}
|
|
|
|
function updateThemeColorMeta() {
|
|
const meta = document.querySelector('meta[name="theme-color"]');
|
|
if (meta) {
|
|
meta.setAttribute('content', state.themeMode === 'light' ? '#eef4fb' : '#08101c');
|
|
}
|
|
}
|
|
|
|
function getDefaultThemePreference() {
|
|
const configMode = theme && theme.config ? theme.config.defaultThemeMode : '';
|
|
if (configMode === 'light' || configMode === 'dark' || configMode === 'system') {
|
|
return configMode;
|
|
}
|
|
return 'system';
|
|
}
|
|
|
|
function resolveThemeMode(preference) {
|
|
if (preference === 'light' || preference === 'dark') {
|
|
return preference;
|
|
}
|
|
return isSystemDarkMode() ? 'dark' : 'light';
|
|
}
|
|
|
|
function bindSystemThemeListener() {
|
|
if (!themeMediaQuery) {
|
|
return;
|
|
}
|
|
const onThemeChange = () => {
|
|
if (state.themePreference === 'system') {
|
|
applyThemeMode();
|
|
render();
|
|
}
|
|
};
|
|
if (typeof themeMediaQuery.addEventListener === 'function') {
|
|
themeMediaQuery.addEventListener('change', onThemeChange);
|
|
return;
|
|
}
|
|
if (typeof themeMediaQuery.addListener === 'function') {
|
|
themeMediaQuery.addListener(onThemeChange);
|
|
}
|
|
}
|
|
|
|
function getThemeMediaQuery() {
|
|
if (!window.matchMedia) {
|
|
return null;
|
|
}
|
|
return window.matchMedia("(prefers-color-scheme: dark)");
|
|
}
|
|
|
|
function isSystemDarkMode() {
|
|
return Boolean(themeMediaQuery && themeMediaQuery.matches);
|
|
}
|
|
|
|
function getMetricsBaseUrl() {
|
|
const baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : '';
|
|
return String(baseUrl || '').replace(/\/+$/, '');
|
|
}
|
|
|
|
function getUplinkHeadline() {
|
|
if (!Number.isFinite(state.uplinkMbps)) {
|
|
return "Delivering -- Mbps";
|
|
}
|
|
return "Delivering " + formatUplinkMbps(state.uplinkMbps) + " Mbps";
|
|
}
|
|
|
|
function renderUplinkMetricStack() {
|
|
return [
|
|
'<div class="hero-metric-line">Delivering</div>',
|
|
'<div class="hero-metric-value"><em>' + escapeHtml(getUplinkValueText()) + '</em></div>',
|
|
'<div class="hero-metric-line">Uplink Bandwidth</div>'
|
|
].join("");
|
|
}
|
|
|
|
function getUplinkValueText() {
|
|
if (!Number.isFinite(state.uplinkMbps)) {
|
|
return "-- Mbps";
|
|
}
|
|
return formatUplinkMbps(state.uplinkMbps) + " Mbps";
|
|
}
|
|
|
|
function formatUplinkMbps(value) {
|
|
if (value >= 100) {
|
|
return String(Math.round(value));
|
|
}
|
|
if (value >= 10) {
|
|
return value.toFixed(1);
|
|
}
|
|
return value.toFixed(2);
|
|
}
|
|
|
|
function isValidRoute(route) {
|
|
return [
|
|
"overview",
|
|
"access-history",
|
|
"nodes",
|
|
"ipv6-nodes",
|
|
"tickets",
|
|
"real-name",
|
|
"security"
|
|
].indexOf(route) !== -1;
|
|
}
|
|
|
|
function getRouteTitle(route) {
|
|
if (route === "access-history") {
|
|
return "访问记录";
|
|
}
|
|
if (route === "nodes") {
|
|
return "节点";
|
|
}
|
|
if (route === "ipv6-nodes") {
|
|
return "IPv6 节点";
|
|
}
|
|
if (route === "notices") {
|
|
return "知识库";
|
|
}
|
|
if (route === "tickets") {
|
|
return "工单";
|
|
}
|
|
if (route === "real-name") {
|
|
return "\u5b9e\u540d\u8ba4\u8bc1";
|
|
}
|
|
if (route === "security") {
|
|
return "账号安全";
|
|
}
|
|
return "总览";
|
|
}
|
|
|
|
function tabButton(mode, label, active) {
|
|
return '<button class="form-tab ' + (active ? "active" : "") + '" data-action="switch-mode" data-mode="' + mode + '">' + label + "</button>";
|
|
}
|
|
|
|
function getStoredToken() {
|
|
const candidates = [];
|
|
collectStorageValues(window.localStorage, ['access_token', 'auth_data', '__nebula_auth_data__', 'token', 'auth_token'], candidates);
|
|
collectStorageValues(window.sessionStorage, ['access_token', 'auth_data', '__nebula_auth_data__', 'token', 'auth_token'], candidates);
|
|
for (let i = 0; i < candidates.length; i += 1) {
|
|
const token = extractToken(candidates[i]);
|
|
if (token) {
|
|
return token;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function saveToken(token) {
|
|
const normalized = normalizeAuthHeader(token);
|
|
window.localStorage.setItem('access_token', normalized);
|
|
window.localStorage.setItem('auth_data', normalized);
|
|
window.localStorage.setItem('__nebula_auth_data__', normalized);
|
|
window.sessionStorage.setItem('access_token', normalized);
|
|
window.sessionStorage.setItem('auth_data', normalized);
|
|
window.sessionStorage.setItem('__nebula_auth_data__', normalized);
|
|
}
|
|
|
|
function clearToken() {
|
|
window.localStorage.removeItem("access_token");
|
|
window.localStorage.removeItem("auth_data");
|
|
window.localStorage.removeItem("__nebula_auth_data__");
|
|
window.sessionStorage.removeItem("access_token");
|
|
window.sessionStorage.removeItem("auth_data");
|
|
window.sessionStorage.removeItem("__nebula_auth_data__");
|
|
state.authToken = "";
|
|
}
|
|
|
|
function getStoredIpv6Token() {
|
|
var stored = "";
|
|
try {
|
|
stored = window.localStorage.getItem("__nebula_ipv6_auth_data__") || "";
|
|
} catch (e) {
|
|
stored = "";
|
|
}
|
|
return stored;
|
|
}
|
|
|
|
function saveIpv6Token(token) {
|
|
window.localStorage.setItem("__nebula_ipv6_auth_data__", normalizeAuthHeader(token));
|
|
}
|
|
|
|
function clearIpv6Token() {
|
|
window.localStorage.removeItem("__nebula_ipv6_auth_data__");
|
|
state.ipv6AuthToken = "";
|
|
}
|
|
|
|
function resetIpv6DashboardState() {
|
|
clearIpv6Token();
|
|
state.ipv6User = null;
|
|
state.ipv6Subscribe = null;
|
|
state.ipv6Eligibility = null;
|
|
state.ipv6Servers = [];
|
|
state.ipv6SessionOverview = null;
|
|
state.ipv6CachedSubNodes = null;
|
|
window.sessionStorage.removeItem("__nebula_ipv6_sub_content__");
|
|
}
|
|
|
|
function handleIpv6AuthFailure(error) {
|
|
if (!error || (error.status !== 401 && error.status !== 403)) {
|
|
return false;
|
|
}
|
|
resetIpv6DashboardState();
|
|
return true;
|
|
}
|
|
|
|
function resetDashboard() {
|
|
state.user = null;
|
|
state.subscribe = null;
|
|
state.stats = null;
|
|
state.knowledge = [];
|
|
state.tickets = [];
|
|
state.selectedTicketId = null;
|
|
state.selectedTicket = null;
|
|
state.servers = [];
|
|
state.ipv6AuthToken = "";
|
|
state.ipv6User = null;
|
|
state.ipv6Subscribe = null;
|
|
state.ipv6Eligibility = null;
|
|
state.ipv6Servers = [];
|
|
state.sessionOverview = null;
|
|
state.ipv6SessionOverview = null;
|
|
state.realNameVerification = null;
|
|
state.appConfig = null;
|
|
state.cachedSubNodes = null;
|
|
state.ipv6CachedSubNodes = null;
|
|
state.loading = false;
|
|
}
|
|
|
|
function applyCustomBackground() {
|
|
if (!theme.config || !theme.config.backgroundUrl) {
|
|
document.documentElement.style.removeProperty('--nebula-bg-image');
|
|
return;
|
|
}
|
|
document.documentElement.style.setProperty('--nebula-bg-image', 'url("' + String(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")');
|
|
}
|
|
|
|
function getVerifyToken() {
|
|
const url = new URL(window.location.href);
|
|
const directVerify = url.searchParams.get('verify');
|
|
if (directVerify) {
|
|
return directVerify;
|
|
}
|
|
if (window.location.hash.indexOf('?') !== -1) {
|
|
return new URLSearchParams(window.location.hash.split('?')[1] || '').get('verify');
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function clearVerifyToken() {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('verify');
|
|
if (window.location.hash.indexOf('?') !== -1) {
|
|
window.history.replaceState({}, '', url.pathname + url.search + window.location.hash.split('?')[0]);
|
|
return;
|
|
}
|
|
window.history.replaceState({}, '', url.pathname + url.search);
|
|
}
|
|
|
|
function showMessage(message, type) {
|
|
const container = document.getElementById('nebula-toasts');
|
|
if (!container) return;
|
|
|
|
if (!message) {
|
|
container.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = '<div class="nebula-toast-container"><div class="message-banner ' + escapeHtml(type || '') + '">' + escapeHtml(message) + '</div></div>';
|
|
|
|
// Auto clear after 4s
|
|
if (state.messageTimeout) clearTimeout(state.messageTimeout);
|
|
state.messageTimeout = window.setTimeout(() => {
|
|
container.innerHTML = '';
|
|
}, 4000);
|
|
}
|
|
|
|
function hideLoader() {
|
|
if (!loader) {
|
|
return;
|
|
}
|
|
loader.classList.add("is-hidden");
|
|
window.setTimeout(function () {
|
|
if (loader && loader.parentNode) {
|
|
loader.parentNode.removeChild(loader);
|
|
loader = null;
|
|
}
|
|
}, 350);
|
|
}
|
|
|
|
function collectStorageValues(storage, keys, target) {
|
|
if (!storage) {
|
|
return;
|
|
}
|
|
for (let i = 0; i < keys.length; i += 1) {
|
|
pushCandidate(storage.getItem(keys[i]), target);
|
|
}
|
|
}
|
|
|
|
function pushCandidate(value, target) {
|
|
if (value === null || typeof value === "undefined" || value === "") {
|
|
return;
|
|
}
|
|
target.push(value);
|
|
}
|
|
|
|
function extractToken(value, depth) {
|
|
depth = depth || 0;
|
|
if (depth > 4 || value === null || typeof value === 'undefined') {
|
|
return '';
|
|
}
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
if (trimmed.indexOf('Bearer ') === 0) {
|
|
return trimmed;
|
|
}
|
|
if (/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(\.[A-Za-z0-9\-_.+/=]+)?$/.test(trimmed)) {
|
|
return 'Bearer ' + trimmed;
|
|
}
|
|
if (/^[A-Za-z0-9\-_.+/=]{24,}$/.test(trimmed) && trimmed.indexOf('{') === -1) {
|
|
return 'Bearer ' + trimmed;
|
|
}
|
|
if ((trimmed.charAt(0) === '{' && trimmed.charAt(trimmed.length - 1) === '}') ||
|
|
(trimmed.charAt(0) === '[' && trimmed.charAt(trimmed.length - 1) === ']')) {
|
|
try {
|
|
return extractToken(JSON.parse(trimmed), depth + 1);
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
if (Array.isArray(value)) {
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
const arrayToken = extractToken(value[i], depth + 1);
|
|
if (arrayToken) {
|
|
return arrayToken;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
if (typeof value === 'object') {
|
|
const directKeys = ['access_token', 'auth_data', 'token', 'Authorization', 'authorization'];
|
|
for (let j = 0; j < directKeys.length; j += 1) {
|
|
if (Object.prototype.hasOwnProperty.call(value, directKeys[j])) {
|
|
const directToken = extractToken(value[directKeys[j]], depth + 1);
|
|
if (directToken) {
|
|
return directToken;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function normalizeAuthHeader(token) {
|
|
const trimmed = String(token || '').trim();
|
|
if (!trimmed) {
|
|
return '';
|
|
}
|
|
if (trimmed.indexOf('Bearer ') === 0) {
|
|
return trimmed;
|
|
}
|
|
return 'Bearer ' + trimmed;
|
|
}
|
|
|
|
function copyText(value, successMessage) {
|
|
if (!value) {
|
|
return;
|
|
}
|
|
navigator.clipboard.writeText(value).then(function () {
|
|
showMessage(successMessage || "内容已复制到剪贴板", "success");
|
|
render();
|
|
}).catch(function () {
|
|
showMessage("复制失败,请尝试手动复制", "error");
|
|
render();
|
|
});
|
|
}
|
|
|
|
function formatTraffic(value) {
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let size = Number(value || 0);
|
|
let index = 0;
|
|
while (size >= 1024 && index < units.length - 1) {
|
|
size /= 1024;
|
|
index += 1;
|
|
}
|
|
return size.toFixed(size >= 10 || index === 0 ? 0 : 1) + ' ' + units[index];
|
|
}
|
|
|
|
function formatMoney(value, symbol) {
|
|
return (symbol || '$') + (Number(value || 0) / 100).toFixed(2);
|
|
}
|
|
|
|
function formatSpeed(value) {
|
|
if (!value) {
|
|
return '不限';
|
|
}
|
|
return value + ' Mbps';
|
|
}
|
|
|
|
function formatLimit(value) {
|
|
if (value === null || typeof value === "undefined" || value === "") {
|
|
return "不限";
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
var timestamp = toTimestamp(value);
|
|
if (!timestamp) {
|
|
return "-";
|
|
}
|
|
return new Date(timestamp * 1000).toLocaleString();
|
|
}
|
|
|
|
function toTimestamp(value) {
|
|
if (!value) {
|
|
return null;
|
|
}
|
|
if (typeof value === 'number') {
|
|
return value;
|
|
}
|
|
const parsed = Date.parse(value);
|
|
return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
|
|
}
|
|
|
|
function truncate(text, maxLength) {
|
|
const str = String(text || '');
|
|
if (str.length <= maxLength) {
|
|
return str;
|
|
}
|
|
return str.slice(0, maxLength - 1) + '...';
|
|
}
|
|
|
|
function firstNonEmpty(values) {
|
|
for (let i = 0; i < values.length; i += 1) {
|
|
if (values[i]) {
|
|
return values[i];
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/g, "&")
|
|
.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();
|
|
}
|
|
};
|
|
}
|
|
})();
|