diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index c69d4e4..7350727 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -57,7 +57,6 @@ jobs: run: | cp README.md dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/README.md cp .env.example dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/.env.example - cp sqldes dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/sqldes cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/ cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/ cp -r submodule/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/submodule/ diff --git a/cmd/api/main_entry.go b/cmd/api/main_entry.go index 3d31f47..0e2ad17 100644 --- a/cmd/api/main_entry.go +++ b/cmd/api/main_entry.go @@ -295,6 +295,7 @@ func registerAdminRoutesV2(v2 *gin.RouterGroup) { admin.GET("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigFetch) admin.POST("/user-add-ipv6-subscription/config", handler.AdminIPv6SubscriptionConfigSave) admin.POST("/user-add-ipv6-subscription/enable/:userId", handler.AdminIPv6SubscriptionEnable) + admin.POST("/user-add-ipv6-subscription/disable/:userId", handler.AdminIPv6SubscriptionDisable) admin.POST("/user-add-ipv6-subscription/sync-password/:userId", handler.AdminIPv6SubscriptionSyncPassword) } diff --git a/frontend/admin/app.js b/frontend/admin/app.js index e056ef8..6514a27 100644 --- a/frontend/admin/app.js +++ b/frontend/admin/app.js @@ -330,6 +330,7 @@ } if (action === "node-edit") { + event.preventDefault(); const item = state.nodes.find((row) => String(row.id) === actionEl.getAttribute("data-id")); state.modal = { type: "node", data: item || {} }; render(); @@ -337,6 +338,7 @@ } if (action === "node-copy") { + event.preventDefault(); await adminPost(`${cfg.api.adminBase}/server/manage/copy`, { id: Number(actionEl.getAttribute("data-id")) }); @@ -345,6 +347,7 @@ } if (action === "node-delete") { + event.preventDefault(); if (confirm("确认删除该节点吗?")) { await adminPost(`${cfg.api.adminBase}/server/manage/drop`, { id: Number(actionEl.getAttribute("data-id")) @@ -354,6 +357,32 @@ return; } + if (action === "ipv6-enable") { + const id = actionEl.getAttribute("data-user-id"); + // Use prompt with plan selection for simplicity in this minimal framework + const planNames = state.plans.map(p => `${p.id}: ${p.name}`).join("\n"); + const planID = prompt(`请输入要分配给该用户的 IPv6 套餐 ID:\n\n${planNames}`, ""); + if (planID === null) return; + + await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/enable/${id}`, { plan_id: Number(planID) || 0 }); + await hydrateRoute(); + return; + } + + if (action === "ipv6-disable") { + const id = actionEl.getAttribute("data-user-id"); + if (confirm("确认要关闭该用户的 IPv6 订阅吗?(将封禁从属账号)")) { + await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/disable/${id}`, {}); + await hydrateRoute(); + } + return; + } + + if (action === "ipv6-sync-password") { + await adminPost(`${cfg.api.adminBase}/user-add-ipv6-subscription/sync-password/${actionEl.getAttribute("data-user-id")}`, {}); + return; + } + if (action === "node-toggle-visible") { event.preventDefault(); const input = actionEl.querySelector("input"); @@ -963,7 +992,8 @@ renderStatus(item.status_label || item.status || "-"), escapeHtml(formatDate(item.updated_at)), renderActionRow([ - buttonAction("开通/同步", "ipv6-enable", null, `data-user-id="${item.id}"`), + buttonAction("开通/更新", "ipv6-enable", null, `data-user-id="${item.id}"`), + buttonAction("关闭/封禁", "ipv6-disable", null, `data-user-id="${item.id}"`), buttonAction("同步密码", "ipv6-sync-password", null, `data-user-id="${item.id}"`) ]) ]); diff --git a/frontend/theme/Nebula/assets/app.js b/frontend/theme/Nebula/assets/app.js index 81d4430..390d7ac 100644 --- a/frontend/theme/Nebula/assets/app.js +++ b/frontend/theme/Nebula/assets/app.js @@ -202,7 +202,7 @@ return Object.assign({ enabled: true, status: "unverified", - status_label: "未认证", + status_label: "锟借娋鎭曢渹锟?, can_submit: true, real_name: "", identity_no_masked: "", @@ -430,7 +430,7 @@ var form = actionEl.closest("form"); var email = form.querySelector("[name='email']").value; if (!email) { - showMessage("请输入邮箱地址", "warning"); + showMessage("闇傜憗锟斤拷浠匡拷铦炲嫍皈憪锟斤拷", "warning"); return; } actionEl.disabled = true; @@ -439,10 +439,10 @@ auth: false, body: { email: email } }).then(function () { - showMessage("验证码已发送,请检查您的邮箱", "success"); + showMessage("鎾夎锟斤拷锟芥瓏锟界吘锟斤拷锟介渹鐟燂拷锟戒氦锟斤拷锟斤拷铦烇拷", "success"); startCountdown(actionEl, 60); }).catch(function (error) { - showMessage(error.message || "获取验证码失败", "error"); + showMessage(error.message || "锟界憰锟芥拤璜癸拷锟斤拷浠冮煇锟?, "error"); actionEl.disabled = false; }); return; @@ -450,7 +450,7 @@ } function startCountdown(button, seconds) { - var originalText = "获取验证码"; + var originalText = "锟界憰锟芥拤璜癸拷锟斤拷"; button.disabled = true; var current = seconds; var timer = setInterval(function () { @@ -517,14 +517,14 @@ // 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(", ") : "" + "锟斤拷锟斤拷婊ㄥ: " + (server.name || "锟借姼穰盯锟芥哗锟斤拷锟?), + "锟斤拷锟借潗椁冿拷: " + (server.type || "锟借姲浒?), + "锟斤拷锟斤拷婊拷: " + (server.rate || 1) + "x", + "锟斤拷锟斤拷鍡嗭拷锟? " + (server.is_online ? "锟藉嚱鐟? : "铦抽鐟?), + tags.length ? "锟斤拷锟斤拷锟藉€? " + tags.join(", ") : "" ].filter(Boolean).join("\n"); - copyText(info, "已复制节点基本信息(订阅链接当前不可达)"); + copyText(info, "鎾岃劊锟斤拷鍡夛拷锟藉鎶咃拷鐮岀笐锟借崝锟介湀锝侊拷锟芥毠穰粬鏁舵瀼锟介姖婊氳櫨棰叉锟?); } function showNodeInfoModal(server, clashYaml, standardLink) { @@ -537,24 +537,24 @@ modal.style.width = "min(540px, 100%)"; modal.innerHTML = [ - '

' + escapeHtml(server.name || "节点详情") + '

', + '

' + escapeHtml(server.name || "锟斤拷锟介渹琛岋拷") + '

', '
', '
' + - '节点类型' + + '锟斤拷锟借潗椁冿拷' + '' + escapeHtml(server.type || "unknown").toUpperCase() + '' + '
', '
' + - '结算倍率' + + '铦忔⒍锟斤拷婊拷' + '' + escapeHtml(String(server.rate || 1)) + 'x' + '
', '
', - '
', + '
', '
' + escapeHtml(maskClashYaml(clashYaml)) + '
', '
', '' ].join(""); @@ -566,12 +566,12 @@ }; modal.querySelector("#node-info-copy-link").onclick = function() { - copyText(standardLink, "标准配置链接已复制"); + copyText(standardLink, "锟斤拷锟斤拷婊ㄨ敪锟芥毠穰粬鎾岃劊锟斤拷锟?); document.body.removeChild(overlay); }; modal.querySelector("#node-info-copy-clash").onclick = function() { - copyText(clashYaml, "Clash 配置已复制"); + copyText(clashYaml, "Clash 锟芥花钄拰鑴o拷锟斤拷"); document.body.removeChild(overlay); }; @@ -581,15 +581,15 @@ } function showCorsHelpModal(subUrl, nodeId, isIpv6) { - var title = (isIpv6 ? "IPv6 " : "") + "同步订阅数据"; + var title = (isIpv6 ? "IPv6 " : "") + "锟藉硶閮婇湀锝侊拷锟藉敵鏃?; showNebulaModal({ title: title, - content: "由于浏览器跨域 (CORS) 策略限制,无法直接从网页获取节点配置链接。请手动同步一次订阅:\n\n1. 点击下方按钮在浏览器中打开订阅链接\n2. 复制网页显示的全部内容\n3. 返回此处点击“去粘贴”并存入系统", - confirmText: "打开订阅链接", - cancelText: "我要手动粘贴", + content: "锟芥浌锟界槡璁涳拷锟藉埢妤婏拷锟?(CORS) 铦戰夎&锟金硷拷鍤楀硶锟界槣闁у噿锟戒供锟借澋鐓锯柍锟界憰锟斤拷锟斤拷锟芥花钄拷鏆桂栵拷锟界獔锟借稷啞锟藉硶閮婇姖锟界攬鈭熸仴锟斤拷锟絓n\n1. 锟藉绋姖皈⒉洳拷鍘板兗锟藉喗锟介柅锟借啣閵濆墱锟芥挊锟介湀锝侊拷锟芥毠穰粬\n2. 鎲粴锟借澋鐓锯柍锟芥泟鍏э拷锟斤拷锟藉吀锟芥懓閳緉3. 椁堭オ滐拷鐢囨枃锟斤拷瀛电ì锟金▍抚铦庝亝鏂愶拷鍢ュ儙鎽潈锟借潫椁岋拷", + confirmText: "锟芥瀼锟介湀锝侊拷锟芥毠穰粬", + cancelText: "锟金革拷锟借稷啞铦庝亝鏂?, onConfirm: function() { window.open(subUrl, "_blank"); - showMessage("订阅已在新标签页打开,请复制其内容", "info"); + showMessage("闇堬絹锟芥拰鑴i妬锟藉敵锟借潙鏆糕柍锟芥瀼锟藉殫璜圭獔鎲粴锟斤拷鍡咃拷鎽帮拷", "info"); }, onCancel: function() { showManualPasteModal(nodeId, isIpv6); @@ -609,12 +609,12 @@ var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes"; modal.innerHTML = [ - '

粘贴 ' + (isIpv6 ? "IPv6 " : "") + '订阅内容

', - '

请将你刚才复制的订阅源代码粘贴在下方。我们将为您解析并保存。

', - '', + '

铦庝亝鏂?' + (isIpv6 ? "IPv6 " : "") + '闇堬絹锟斤拷锟芥崋

', + '

闇傜憰锟介浛鎯╋拷锟芥粴锟斤拷鍡ワ拷闇堬絹锟界殲穑偡瑾拷锟斤拷闊愭父閵侀姖皈⒉洳拷锟斤拷闅炵爫锟介姖绠革拷闁拷锟芥挓鍡★拷鎽々锟斤拷

', + '', '' ].join(""); @@ -631,10 +631,10 @@ window.sessionStorage.setItem(storageKey, content); state[cacheKey] = parseSubscriptionToLines(content); document.body.removeChild(overlay); - showMessage("订阅数据同步成功!", "success"); + showMessage("闇堬絹锟斤拷鍞虫椏锟藉硶閮婏拷穑偧锟藉殫锟?, "success"); if (nodeId) handleCopyNodeInfo(nodeId); } else { - showMessage("内容不能为空", "error"); + showMessage("锟斤拷鎹嗛姖婊╋拷閵濈畤寰?, "error"); } }; } @@ -925,14 +925,14 @@ saveIpv6Token(payload.auth_data); state.ipv6AuthToken = getStoredIpv6Token(); } - showMessage("IPv6 订阅已开启,正在刷新...", "success"); + showMessage("IPv6 闇堬絹锟芥拰鑴o拷锟借崝锟界攪锟介妬锟界憻榘?..", "success"); // Try to login to IPv6 account if possible, or just refresh dashboard // Since we don't have the password here (state doesn't keep it), // the user might need to sync password first or we assume it was synced during creation. await loadDashboard(true); render(); } catch (error) { - showMessage(error.message || "开启失败", "error"); + showMessage(error.message || "鎾橈拷锟借嚞浠冮煇锟?, "error"); } finally { actionEl.disabled = false; } @@ -942,9 +942,9 @@ actionEl.disabled = true; try { await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" }); - showMessage("密码已同步到 IPv6 账户", "success"); + showMessage("鎾栵拷锟芥拰鑴o拷鐢囦簷锟?IPv6 闊愯锟?, "success"); } catch (error) { - showMessage(error.message || "同步失败", "error"); + showMessage(error.message || "锟藉硶閮婃啳姊彇", "error"); } finally { actionEl.disabled = false; } @@ -1007,12 +1007,12 @@ message: data.message || "" } }).then(function () { - showMessage("工单已创建", "success"); + showMessage("鎾屼簷锟芥拰鑴o拷鎾憋拷", "success"); state.selectedTicketId = null; state.selectedTicket = null; return loadDashboard(true); }).then(render).catch(function (error) { - showMessage(error.message || "工单创建失败", "error"); + showMessage(error.message || "鎾屼簷锟斤拷馥暒閬f啳姊彇", "error"); render(); }).finally(function () { if (submitButton) { @@ -1035,13 +1035,13 @@ state.realNameVerification = verification; showMessage( verification && verification.status === "approved" - ? "实名认证已通过。" - : "认证信息已提交,请等待审核。", + ? "鎽梆Ъ拷闇堟柟锟芥拰鑴わ拷鏈烇拷锟斤拷" + : "闇堟柟锟介澖鈯ワ拷鎾岃劔锟介埈姝癸拷闇傜憺锟芥暫锟芥仯锟借┗锟斤拷", "success" ); return loadDashboard(true); }).then(render).catch(function (error) { - showMessage(error.message || "认证提交失败", "error"); + showMessage(error.message || "闇堟柟锟斤拷穑偡婕辨啳姊彇", "error"); render(); }).finally(function () { if (submitButton) { @@ -1053,7 +1053,7 @@ if (formType === "change-password") { if (data.new_password !== data.confirm_password) { - showMessage("两次输入的新密码不一致", "error"); + showMessage("閵濇枟娲婚⒉鏋忥拷锟斤拷榘垫挅锟斤拷閵濇虎锟斤拷锟?, "error"); if (submitButton) submitButton.disabled = false; return; } @@ -1064,10 +1064,10 @@ new_password: data.new_password || "" } }).then(function () { - showMessage("密码已修改,建议在 IPv6 节点同步新密码", "success"); + showMessage("鎾栵拷锟芥拰鑴栬€拷灏嶏拷鎾辩畡鎮咃拷锟?IPv6 锟斤拷锟斤拷宄曢儕锟藉暎锟斤拷锟?, "success"); form.reset(); }).catch(function (error) { - showMessage(error.message || "密码修改失败", "error"); + showMessage(error.message || "鎾栵拷锟介澖鏍笺嚎鎲鎻?, "error"); }).finally(function () { if (submitButton) { submitButton.disabled = false; @@ -1086,11 +1086,11 @@ password: data.password } }).then(function () { - showMessage("密码已重置,请使用新密码登录", "success"); + showMessage("鎾栵拷锟芥拰鑴わ拷铦垫锟介渹鐟氳潤锟藉喗榘垫挅锟斤拷锟介锟?, "success"); state.mode = "login"; render(); }).catch(function (error) { - showMessage(error.message || "重置失败", "error"); + showMessage(error.message || "锟芥花钄啳姊彇", "error"); }).finally(function () { if (submitButton) { submitButton.disabled = false; @@ -1147,9 +1147,9 @@ function handleResetSecurity(actionEl, isIpv6) { showNebulaModal({ - title: "安全提醒", - content: "确定要重置订阅令牌吗?重置令牌后,原本的订阅地址将失效,你需要重新获取并配置订阅。", - confirmText: "确认重置", + title: "鎽板導锟斤拷闉撅拷", + content: "铦栨锟介柆锟斤拷铦垫牚鎭ワ拷锟借獦锟斤拷锟藉殫缃革拷铦垫瑾橈拷锟斤拷鍤楋拷锟斤拷绁夛拷闇堬絹锟斤拷鍟o拷鎾狅拷浠冿拷锟斤拷闆筐3囷拷闁拷锟斤拷鍟楃巩锟界鍍庯拷婊ㄨ敪闇堬絹锟斤拷锟?, + confirmText: "铦栨牚鎭曪拷婊ㄨ敪", onConfirm: function () { actionEl.disabled = true; fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) { @@ -1158,10 +1158,10 @@ if (sub && subscribeUrl) { sub.subscribe_url = subscribeUrl; } - showMessage((isIpv6 ? "IPv6 " : "") + "订阅令牌已重置", "success"); + showMessage((isIpv6 ? "IPv6 " : "") + "闇堬絹锟介殲鏂わ拷鎾岃劋锟借澋锟?, "success"); render(); }).catch(function (error) { - showMessage(error.message || "重置失败", "error"); + showMessage(error.message || "锟芥花钄啳姊彇", "error"); render(); }).finally(function () { actionEl.disabled = false; @@ -1230,7 +1230,7 @@ state.selectedTicket = unwrap(response); render(); }).catch(function (error) { - showMessage(error.message || "工单详情加载失败", "error"); + showMessage(error.message || "鎾屼簷锟介渹琛岋拷锟姐皹铦告啳姊彇", "error"); render(); }).finally(function () { actionEl.disabled = false; @@ -1312,14 +1312,14 @@ '
', (theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [ '
', - tabButton("login", "登录", state.mode === "login"), - (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "注册", state.mode === "register") : "", - tabButton("forget-password", "找回密码", state.mode === "forget-password"), + tabButton("login", "锟介锟?, state.mode === "login"), + (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "鐦滃吀锟?, state.mode === "register") : "", + tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"), "
" ].join("") : [ '
', - tabButton("login", "登录", state.mode === "login"), - tabButton("forget-password", "找回密码", state.mode === "forget-password"), + tabButton("login", "锟介锟?, state.mode === "login"), + tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"), "
" ].join(""), renderAuthPanelHeading(), @@ -1353,7 +1353,7 @@ '
', renderThemeToggle(), isLoggedIn - ? '' + ? '
", "" @@ -1436,13 +1436,18 @@ return html; } + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { + return [ + '
', + '
', + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { return [ '
', '
', 'Subscription', "

" + escapeHtml(theme.title || "Nebula") + "

", - "

" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " · " + escapeHtml((state.user && state.user.email) || "") + "

", + "

" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " 绻?" + escapeHtml((state.user && state.user.email) || "") + "

", '
', dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"), dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"), @@ -1471,11 +1476,11 @@ var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : ""); return [ '
', - '
访问概览

安全状态

', + '
访问记录

设备概览

', '
', - kpiBox("设备数限制(IPv4/IPv6)", limitDisplay), + 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)), + kpiBox("过期时间", formatDate(state.subscribe && state.subscribe.expired_at)), '
', '
' ].join(""); @@ -1531,7 +1536,7 @@ content = content.replace(/
', - sidebarLink("real-name", "\u5b9e\u540d\u8ba4\u8bc1", "\u63d0\u4ea4\u4e0e\u67e5\u770b\u5b9e\u540d\u5ba1\u6838\u72b6\u6001") + '
' + sidebarLink("real-name", "实名认证", "提交与查看实名审核状态") + '
' ); if (innerOnly) return content; @@ -1579,78 +1584,10 @@ return renderSubscribeSection(extraClass, true); } - function renderSubscribeSection(extraClass, isIpv6) { - extraClass = extraClass || "span-7"; - var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; - var user = isIpv6 ? state.ipv6User : state.user; - var ipv6Eligibility = state.ipv6Eligibility || {}; - var prefix = isIpv6 ? "IPv6 " : ""; - var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe"; - var resetAction = isIpv6 ? "reset-ipv6-security" : "reset-security"; - - if (isIpv6 && !state.ipv6AuthToken) { - // If IPv6 not enabled, we still want to show the section with an "Enable" button - // But we need to make sure we don't crash on 'sub' or 'user' - } else if (isIpv6 && !sub) { - return ""; - } - - var html = [ - '
', - '
' + prefix + '订阅

连接工具

', - '', - isIpv6 ? '' : '', - "
", - '
', - !isIpv6 ? kpiBox("套餐", escapeHtml((sub && sub.plan && sub.plan.name) || "暂无套餐")) : "", - kpiBox("限速", formatSpeed(sub && sub.speed_limit)), - kpiBox("重置日", String((sub && sub.reset_day) || "-")), - !isIpv6 ? kpiBox("邮箱", '' + escapeHtml((user && user.email) || "-") + "") : "", - "
", - (isIpv6 && !state.ipv6AuthToken) ? '' : '', - "
" - ].join(""); - if (isIpv6 && !state.ipv6AuthToken) { - if (!ipv6Eligibility.allowed) { - html = html.replace( - /
', + '
会话

访问记录

', renderSessions(sessions), "" ].join(""); @@ -1660,7 +1597,7 @@ return [ '
', '
', - '
' + (isIpv6 ? "IPv6 " : "") + '节点

' + (isIpv6 ? "IPv6 " : "") + '服务节点' + '

', + '
' + (isIpv6 ? "IPv6 " : "") + '节点

' + (isIpv6 ? "IPv6 " : "") + '服务器列表

', '
', '', '
', @@ -1674,21 +1611,20 @@ if (!articles.length) { return [ '
', - '
知识库

知识内容

', - '
暂时没有内容。
', + '
知识库

常见问题

', + '
暂无常见问题数据
', "
" ].join(""); } - // Group articles by category if possibly present, or just list them return [ '
', - '
知识库

全部文章

', + '
知识库

内容列表

', '
' + articles.map(function (article) { return [ '
', '
', - "" + escapeHtml(article.title || "未命名文章") + "", + "" + escapeHtml(article.title || "未知标题") + "", '' + escapeHtml(truncate(article.body || article.content || "", 160)) + "", "
", article.category ? '' + escapeHtml(article.category) + '' : formatDate(article.updated_at || article.created_at), @@ -1702,7 +1638,7 @@ function renderTicketSection(tickets) { return [ '
', - '
工单

工单列表

', + '
工单

我的工单

', renderTickets(tickets), "
" ].join(""); @@ -1711,12 +1647,12 @@ function renderTicketComposer() { return [ '
', - '
新建

创建工单

', + '
提问

发起新工单

', '
', - '
', - '
', - '
', - '
', + '
', + '
', + '
', + '
', '
', "
" ].join(""); @@ -1728,10 +1664,10 @@ } return [ '
', - '
详情

' + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + '

', + '
响应中

' + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + '

', '
', kpiBox("状态", renderTicketStatus(ticket.status)), - kpiBox("等级", getTicketLevelLabel(ticket.level)), + kpiBox("优先级", getTicketLevelLabel(ticket.level)), kpiBox("创建时间", formatDate(ticket.created_at)), kpiBox("更新时间", formatDate(ticket.updated_at)), '
', @@ -1739,30 +1675,32 @@ "
" ].join(""); } + "
" + ].join(""); + } - - function renderIpList(ips) { + // G function renderIpList(ips) { var list = ips.slice(); if (!list.length) { - return '
当前还没有检测到在线 IP 记录。
'; + return '
暂无入口请求记录,若您的 IP 发生变动请重新登录。
'; } return '
' + list.map(function (ip) { - return '
在线设备 IP' + escapeHtml(ip) + '
'; + return '
入口 IP' + escapeHtml(ip) + '
'; }).join("") + "
"; } function renderSessions(sessions) { if (!sessions.length) { - return '
当前没有会话记录。
'; + return '
暂无在线会话记录。
'; } return '
' + sessions.map(function (session) { - var tag = session.is_current ? '当前' : '已保存'; - var revoke = session.is_current ? "" : ''; + var tag = session.is_current ? '当前设备' : '其他设备'; + var revoke = session.is_current ? "" : ''; return [ '
', '
', - "" + escapeHtml(session.name || ("Session #" + session.id)) + "", - '创建于 ' + formatDate(session.created_at) + " · 最近使用 " + formatDate(session.last_used_at) + (session.ip ? " · IP " + escapeHtml(session.ip) : "") + "", + "" + escapeHtml(session.name || ("会话 #" + session.id)) + "", + '首次接入 ' + formatDate(session.created_at) + " | 最后活跃 " + formatDate(session.last_used_at) + (session.ip ? " | IP " + escapeHtml(session.ip) : "") + "", "
", '
' + tag + revoke + "
", "
" @@ -1772,7 +1710,7 @@ function renderServers(servers) { if (!servers.length) { - return '
当前没有可用节点。
'; + return '
暂无可用的服务器节点。
'; } var sortedServers = servers.slice().sort(compareServers); @@ -1790,7 +1728,7 @@ } if (!filteredServers.length && servers.length > 0) { - return '
没有匹配的节点。
'; + return '
没有对应条件的搜索结果。
'; } var pageSize = getNodePageSize(); @@ -1810,10 +1748,10 @@ '
', '
', '
', - "" + escapeHtml(server.name || "未命名节点") + "", - '' + escapeHtml(server.type || "node") + "", + "" + escapeHtml(server.name || "未知节点") + "", + '' + escapeHtml(server.type || "节点") + "", "
", - '', + '', "
", '
', nodeSpec("倍率", escapeHtml(String(server.rate || 1)) + "x"), @@ -1829,7 +1767,7 @@ function renderNotices(articles) { if (!articles.length) { - return '
暂时没有内容。
'; + return '
暂无公告内容。
'; } return '
' + articles.map(function (article) { return [ @@ -1846,14 +1784,14 @@ function renderTickets(tickets) { if (!tickets.length) { - return '
当前没有工单记录。
'; + return '
暂无工单回复记录。
'; } return '
' + tickets.map(function (ticket) { return [ '
', '
', "" + escapeHtml(ticket.subject || ("工单 #" + ticket.id)) + "", - '等级 ' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' · 更新时间 ' + formatDate(ticket.updated_at || ticket.created_at) + "", + '优先级: ' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' | 最后更新: ' + formatDate(ticket.updated_at || ticket.created_at) + "", "
", '
' + renderTicketStatus(ticket.status) + '
', "
" @@ -1863,13 +1801,13 @@ function renderTicketMessages(messages) { if (!messages.length) { - return '
当前工单还没有更多消息。
'; + return '
暂无交流记录,请耐心等待管理员回复。
'; } return '
' + messages.map(function (message) { return [ '
', '
', - '' + (message.is_me ? "我" : "客服") + "", + '' + (message.is_me ? "我" : "管理员") + "", '' + formatDate(message.created_at) + "", '' + escapeHtml(message.message || "") + "", "
", @@ -1880,7 +1818,7 @@ function renderTicketStatus(status) { var isOpen = Number(status) === 0; - return '' + (isOpen ? "处理中" : "已关闭") + ""; + return '' + (isOpen ? "受理中" : "已关闭") + ""; } function getTicketLevelLabel(level) { @@ -1993,7 +1931,7 @@ var lastUsedAt = toTimestamp(session.last_used_at); var createdAt = toTimestamp(session.created_at); var expiresAt = toTimestamp(session.expires_at); - var sessionName = session.name || session.device_name || session.user_agent || ("Session #" + (session.id || "")); + var sessionName = session.name || session.device_name || session.user_agent || ("会话 #" + (session.id || "")); var sessionIp = firstNonEmpty([ session.ip, session.ip_address, @@ -2068,7 +2006,7 @@ function renderThemeToggle() { var isLight = state.themeMode === "light"; return [ - '
', + '
', + '
', + '
您的身份证信息仅用于对接第三方认证接口进行验证,我们不会存储或在任何地方展示您的完整身份信息。
', + '
', '', '
' ].join(""); @@ -2278,33 +2215,36 @@ function renderSecuritySection() { return [ '
', - '
账户设置

修改密码

', + '
账户安全

修改密码

', '
', - '
', + '
', '
', '
', '
', - '', + '', '
', '
', "
", '
', - '
安全操作

订阅令牌管理

', + '
安全重置

节点连接令牌重置

', '
', - '
IPv4 订阅
', - '
IPv6 订阅
', + '
IPv4 令牌
', + '
IPv6 令牌
', '
', - '', + '', '
' ].join(""); } function getThemeLogo() { var config = theme.config || {}; + var lightLogo = normalizeUrlValue(config.lightLogoUrl); + var darkLogo = normalizeUrlValue(config.darkLogoUrl); + var themeLogo = normalizeUrlValue(theme.logo); if (state.themeMode === "light") { - return config.lightLogoUrl || config.darkLogoUrl || theme.logo || ""; + return lightLogo || darkLogo || themeLogo || ""; } - return config.darkLogoUrl || theme.logo || config.lightLogoUrl || ""; + return darkLogo || themeLogo || lightLogo || ""; } function updateThemeColorMeta() { @@ -2361,21 +2301,22 @@ function getMetricsBaseUrl() { var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : ""; + baseUrl = normalizeUrlValue(baseUrl); return String(baseUrl || "").replace(/\/+$/, ""); } function getUplinkHeadline() { if (!Number.isFinite(state.uplinkMbps)) { - return "Delivering -- Mbps"; + return "在线带宽速率: -- Mbps"; } - return "Delivering " + formatUplinkMbps(state.uplinkMbps) + " Mbps"; + return "在线带宽速率: " + formatUplinkMbps(state.uplinkMbps) + " Mbps"; } function renderUplinkMetricStack() { return [ - '
Delivering
', + '
在线带宽
', '
' + escapeHtml(getUplinkValueText()) + '
', - '
Uplink Bandwidth
' + '
实时速率详情
' ].join(""); } @@ -2413,22 +2354,22 @@ return "访问记录"; } if (route === "nodes") { - return "节点"; + return "节点列表"; } if (route === "ipv6-nodes") { return "IPv6 节点"; } if (route === "notices") { - return "知识库"; + return "公告中心"; } if (route === "tickets") { - return "工单"; + return "工单回复"; } if (route === "real-name") { - return "\u5b9e\u540d\u8ba4\u8bc1"; + return "实名认证"; } if (route === "security") { - return "账号安全"; + return "账户与安全"; } return "总览"; } @@ -2532,11 +2473,12 @@ } function applyCustomBackground() { - if (!theme.config || !theme.config.backgroundUrl) { + 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(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")'); + document.documentElement.style.setProperty("--nebula-bg-image", 'url("' + String(backgroundUrl).replace(/"/g, '\\"') + '")'); } function getVerifyToken() { @@ -2572,7 +2514,6 @@ container.innerHTML = '
' + escapeHtml(message) + "
"; - // Auto clear after 4s if (state.messageTimeout) clearTimeout(state.messageTimeout); state.messageTimeout = window.setTimeout(function() { container.innerHTML = ""; @@ -2676,10 +2617,10 @@ return; } navigator.clipboard.writeText(value).then(function () { - showMessage(successMessage || "内容已复制到剪贴板", "success"); + showMessage(successMessage || "已成功复制到剪贴板", "success"); render(); }).catch(function () { - showMessage("复制失败,请尝试手动复制", "error"); + showMessage("无法访问剪贴板,请手动选择并复制。", "error"); render(); }); } @@ -2700,15 +2641,15 @@ } function formatSpeed(value) { - if (!value) { - return "不限"; + if (value === null || typeof value === "undefined" || value === "") { + return "无限速"; } return value + " Mbps"; } function formatLimit(value) { if (value === null || typeof value === "undefined" || value === "") { - return "不限"; + return "无限制"; } return String(value); } @@ -2758,37 +2699,131 @@ .replace(/'/g, "'"); } - function renderKnowledgeSection(articles) { - if (!articles.length) { - return [ - '
', - '
Knowledge

Knowledge Base

', - '
No knowledge articles yet.
', - "
" - ].join(""); + function normalizeUrlValue(value) { + var normalized = String(value == null ? "" : value).trim(); + if (!normalized) { + return ""; } - - return [ - '
', - '
Knowledge

Knowledge Base

', - '
' + articles.map(function (article) { - return [ - '" - ].join(""); - }).join("") + "
", - "
" - ].join(""); + 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 getMetricsBaseUrl() { - var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : ""; - baseUrl = normalizeUrlValue(baseUrl); + 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 '

暂无文章正文内容。

'; + } + + return '
' + escapeHtml(content).replace(/\r?\n/g, "
") + '
'; + } + + 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 = [ + '
', + '知识库', + '

' + escapeHtml(article.title || "未知文章") + '

', + '
', + '
', + article.category ? '' + escapeHtml(article.category) + '' : "", + '' + escapeHtml(formatDate(article.updated_at || article.created_at)) + '', + '
', + renderKnowledgeArticleBody(article), + '' + ].join(""); + + document.body.appendChild(overlay); + overlay.appendChild(modal); + + var close = function () { + if (overlay.parentNode) { + overlay.parentNode.removeChild(overlay); + } + }; + + var closeButton = modal.querySelector('[data-role="close-knowledge-article"]'); + if (closeButton) { + closeButton.onclick = close; + } + + overlay.onclick = function (event) { + if (event.target === overlay) { + close(); + } + }; + } + + function showNebulaModal(options) { + var overlay = document.createElement("div"); + overlay.className = "nebula-modal-overlay"; + + var modal = document.createElement("div"); + modal.className = "nebula-modal"; + + var title = document.createElement("h3"); + title.className = "nebula-modal-title"; + title.innerText = options.title || "系统提示"; + + var body = document.createElement("div"); + body.className = "nebula-modal-body"; + body.innerText = options.content || ""; + + var footer = document.createElement("div"); + footer.className = "nebula-modal-footer"; + + var cancelBtn = document.createElement("button"); + cancelBtn.className = "btn btn-ghost"; + cancelBtn.innerText = options.cancelText || "取消操作"; + cancelBtn.onclick = function() { + document.body.removeChild(overlay); + if (options.onCancel) options.onCancel(); + }; + + var confirmBtn = document.createElement("button"); + confirmBtn.className = "btn btn-primary"; + confirmBtn.innerText = options.confirmText || "确认提交"; + confirmBtn.onclick = function() { + document.body.removeChild(overlay); + if (options.onConfirm) options.onConfirm(); + }; + + footer.appendChild(cancelBtn); + footer.appendChild(confirmBtn); + + modal.appendChild(title); + modal.appendChild(body); + modal.appendChild(footer); + overlay.appendChild(modal); + + document.body.appendChild(overlay); + + overlay.onclick = function(e) { + if (e.target === overlay) { + document.body.removeChild(overlay); + if (options.onCancel) options.onCancel(); + } + }; + } +})(); +eUrl = normalizeUrlValue(baseUrl); return String(baseUrl || "").replace(/\/+$/, ""); } @@ -2893,7 +2928,7 @@ var title = document.createElement("h3"); title.className = "nebula-modal-title"; - title.innerText = options.title || "操作确认"; + title.innerText = options.title || "系统提示"; var body = document.createElement("div"); body.className = "nebula-modal-body"; @@ -2904,7 +2939,7 @@ var cancelBtn = document.createElement("button"); cancelBtn.className = "btn btn-ghost"; - cancelBtn.innerText = options.cancelText || "取消"; + cancelBtn.innerText = options.cancelText || "取消操作"; cancelBtn.onclick = function() { document.body.removeChild(overlay); if (options.onCancel) options.onCancel(); @@ -2912,7 +2947,7 @@ var confirmBtn = document.createElement("button"); confirmBtn.className = "btn btn-primary"; - confirmBtn.innerText = options.confirmText || "确定提交"; + confirmBtn.innerText = options.confirmText || "确认提交"; confirmBtn.onclick = function() { document.body.removeChild(overlay); if (options.onConfirm) options.onConfirm(); @@ -2937,3 +2972,4 @@ }; } })(); + diff --git a/frontend/theme/Nebula/assets/app.js.bak b/frontend/theme/Nebula/assets/app.js.bak new file mode 100644 index 0000000..085f767 Binary files /dev/null and b/frontend/theme/Nebula/assets/app.js.bak differ diff --git a/frontend/theme/Nebula/assets/app.js.new b/frontend/theme/Nebula/assets/app.js.new new file mode 100644 index 0000000..163e20d --- /dev/null +++ b/frontend/theme/Nebula/assets/app.js.new @@ -0,0 +1,1478 @@ +(function () { + "use strict"; + + var theme = window.NEBULA_THEME || {}; + var app = document.getElementById("app"); + var loader = document.getElementById("nebula-loader"); + var themeMediaQuery = getThemeMediaQuery(); + + if (!app) { + return; + } + + var state = { + mode: "login", + loading: true, + message: "", + messageType: "", + themePreference: getStoredThemePreference(), + themeMode: "dark", + uplinkMbps: null, + currentRoute: getCurrentRoute(), + nodePage: 1, + localIp: null, + reportedIps: [], + authToken: getStoredToken(), + user: null, + subscribe: null, + stats: null, + knowledge: [], + tickets: [], + selectedTicketId: null, + selectedTicket: null, + servers: [], + ipv6AuthToken: getStoredIpv6Token(), + ipv6User: null, + ipv6Subscribe: null, + ipv6Eligibility: null, + ipv6Servers: [], + sessionOverview: null, + ipv6SessionOverview: null, + realNameVerification: null, + appConfig: null, + isSidebarOpen: false, + serverSearch: "", + cachedSubNodes: null, + ipv6CachedSubNodes: null + }; + + init(); + + async function init() { + applyThemeMode(); + applyCustomBackground(); + loadUplinkMetric(); + window.setInterval(loadUplinkMetric, 5000); + + try { + var verify = getVerifyToken(); + if (verify) { + var verifyResponse = await fetchJson("/api/v1/passport/auth/token2Login?verify=" + encodeURIComponent(verify), { + method: "GET", + auth: false + }); + var verifyPayload = unwrap(verifyResponse); + if (verifyPayload && verifyPayload.auth_data) { + saveToken(verifyPayload.auth_data); + clearVerifyToken(); + state.authToken = getStoredToken(); + } + } + } catch (error) { + showMessage(error.message || "Quick login failed", "error"); + } + + if (state.authToken) { + var loaded = await loadDashboard(); + if (!loaded) { + state.loading = false; + } + } else { + state.loading = false; + } + + render(); + app.addEventListener("click", onClick); + app.addEventListener("input", onInput); + app.addEventListener("submit", onSubmit); + window.addEventListener("hashchange", onRouteChange); + bindSystemThemeListener(); + } + + async function loadDashboard(silent) { + if (!state.authToken) { + state.loading = false; + return false; + } + + state.loading = !silent; + if (!silent) { + render(); + } + + try { + var results = await Promise.all([ + fetchJson("/api/v1/user/info", { method: "GET" }), + fetchJson("/api/v1/user/getSubscribe", { method: "GET" }), + fetchJson("/api/v1/user/getStat", { method: "GET" }), + fetchJson("/api/v1/user/server/fetch", { method: "GET" }), + fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }), + fetchSessionOverview(), + fetchJson("/api/v1/user/comm/config", { method: "GET" }), + fetchRealNameVerificationStatus(), + fetchJson("/api/v1/user/user-add-ipv6-subscription/check", { method: "GET" }) + ]); + + state.user = unwrap(results[0]); + state.subscribe = unwrap(results[1]); + state.stats = unwrap(results[2]); + state.servers = unwrap(results[3]) || []; + state.knowledge = []; + state.tickets = unwrap(results[4]) || []; + state.sessionOverview = results[5]; + state.appConfig = unwrap(results[6]) || {}; + state.realNameVerification = results[7] || null; + state.ipv6Eligibility = unwrap(results[8]) || null; + + if (state.ipv6AuthToken) { + try { + var ipv6Results = await Promise.all([ + fetchJson("/api/v1/user/info", { method: "GET", ipv6: true }), + fetchJson("/api/v1/user/getSubscribe", { method: "GET", ipv6: true }), + fetchJson("/api/v1/user/server/fetch", { method: "GET", ipv6: true }), + fetchSessionOverview(true) + ]); + state.ipv6User = unwrap(ipv6Results[0]); + state.ipv6Subscribe = unwrap(ipv6Results[1]); + state.ipv6Servers = unwrap(ipv6Results[2]) || []; + state.ipv6SessionOverview = ipv6Results[3]; + } catch (e) { + if (handleIpv6AuthFailure(e)) { + console.warn("Nebula: cleared stale IPv6 session after unauthorized response"); + } + console.error("Failed to load IPv6 dashboard data", e); + } + } + + state.nodePage = 1; + state.loading = false; + return true; + } catch (error) { + state.loading = false; + if (error.status === 401 || error.status === 403) { + var hadSession = Boolean(state.authToken); + clearToken(); + resetDashboard(); + showMessage(hadSession ? "Session expired. Please sign in again." : "", hadSession ? "error" : ""); + return false; + } + showMessage(error.message || "Failed to load dashboard", "error"); + return false; + } + } + + async function fetchSessionOverview(isIpv6) { + try { + var response = await fetchJson("/api/v1/user/user-online-devices/get-ip", { method: "GET", ipv6: !!isIpv6 }); + var ipPayload = unwrap(response) || {}; + + var reportedIps = ipPayload.ips || []; + if (!isIpv6) { + state.reportedIps = reportedIps; + } + + var overview = ipPayload.session_overview || { sessions: [], online_ips: [] }; + if (!isIpv6) { + overview.online_ips = state.reportedIps; + } + + return buildSessionOverview(overview.sessions, overview, !!isIpv6); + } catch (error) { + if (isIpv6 && handleIpv6AuthFailure(error)) { + return null; + } + console.error("Nebula: Failed to synchronize online status", error); + // Fallback for sessions if plugin fails + try { + var sessionsFallback = unwrap(await fetchJson("/api/v1/user/getActiveSession", { method: "GET", ipv6: !!isIpv6 })) || []; + return buildSessionOverview(sessionsFallback, {}, !!isIpv6); + } catch (e) { + if (isIpv6 && handleIpv6AuthFailure(e)) { + return null; + } + return buildSessionOverview([], {}, !!isIpv6); + } + } + } + + async function fetchRealNameVerificationStatus() { + try { + var response = await fetchJson("/api/v1/user/real-name-verification/status", { method: "GET" }); + var payload = unwrap(response) || {}; + return Object.assign({ + enabled: true, + status: "unverified", + status_label: "閿熷€熷▼閹洟娓归敓?, + can_submit: true, + real_name: "", + identity_no_masked: "", + notice: "", + reject_reason: "", + submitted_at: null, + reviewed_at: null + }, payload); + } catch (error) { + return { + enabled: false, + status: "unavailable", + status_label: "Unavailable", + can_submit: false, + real_name: "", + identity_no_masked: "", + notice: "", + reject_reason: "", + submitted_at: null, + reviewed_at: null + }; + } + } + + async function fetchJson(url, options) { + options = options || {}; + var headers = { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest" + }; + + if (options.auth !== false) { + var token = options.ipv6 ? (state.ipv6AuthToken || getStoredIpv6Token()) : (state.authToken || getStoredToken()); + if (token) { + headers.Authorization = normalizeAuthHeader(token); + } + } + + var response = await fetch(url, { + method: options.method || "GET", + headers: headers, + credentials: "same-origin", + body: options.body ? JSON.stringify(options.body) : undefined + }); + + var payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + + if (!response.ok) { + var message = payload && (payload.message || payload.error) || "Request failed"; + var err = new Error(message); + err.status = response.status; + throw err; + } + + return payload; + } + + function unwrap(payload) { + if (!payload) { + return null; + } + if (typeof payload.data !== "undefined") { + return payload.data; + } + if (typeof payload.total !== "undefined" && Array.isArray(payload.data)) { + return payload.data; + } + return payload; + } + + function onClick(event) { + if (state.isSidebarOpen) { + var sidebar = event.target.closest(".dashboard-sidebar"); + var toggleBtn = event.target.closest("[data-action='toggle-sidebar']"); + if (!sidebar && !toggleBtn) { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + var sidebarEl = document.querySelector(".dashboard-sidebar"); + var overlayEl = document.querySelector(".sidebar-overlay"); + if (sidebarEl) sidebarEl.classList.remove("is-open"); + if (overlayEl) overlayEl.classList.remove("is-visible"); + } + } + + var actionEl = event.target.closest("[data-action]"); + if (!actionEl) return; + + var action = actionEl.getAttribute("data-action"); + + if (action === "switch-mode") { + state.mode = actionEl.getAttribute("data-mode") || "login"; + showMessage("", ""); + render(); + return; + } + + if (action === "navigate") { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + setCurrentRoute(actionEl.getAttribute("data-route") || "overview"); + return; + } + + if (action === "toggle-sidebar") { + state.isSidebarOpen = !state.isSidebarOpen; + document.documentElement.dataset.sidebarOpen = String(state.isSidebarOpen); + var sidebar = document.querySelector(".dashboard-sidebar"); + var overlay = document.querySelector(".sidebar-overlay"); + if (sidebar) sidebar.classList.toggle("is-open", state.isSidebarOpen); + if (overlay) overlay.classList.toggle("is-visible", state.isSidebarOpen); + return; + } + + if (action === "close-sidebar") { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + var sidebar = document.querySelector(".dashboard-sidebar"); + var overlay = document.querySelector(".sidebar-overlay"); + if (sidebar) sidebar.classList.remove("is-open"); + if (overlay) overlay.classList.remove("is-visible"); + return; + } + + if (action === "toggle-theme-mode") { + toggleThemeMode(); + return; + } + + if (action === "change-node-page") { + setNodePage(Number(actionEl.getAttribute("data-page") || 1)); + return; + } + + if (action === "logout") { + clearToken(); + clearIpv6Token(); + resetDashboard(); + render(); + return; + } + + if (action === "refresh-dashboard") { + loadDashboard(true).then(render); + return; + } + + if (action === "copy-subscribe") { + copyText((state.subscribe && state.subscribe.subscribe_url) || ""); + return; + } + + if (action === "copy-ipv6-subscribe") { + copyText((state.ipv6Subscribe && state.ipv6Subscribe.subscribe_url) || ""); + return; + } + + if (action === "copy-text") { + copyText(actionEl.getAttribute("data-value") || ""); + return; + } + + if (action === "reset-security") { + handleResetSecurity(actionEl); + return; + } + + if (action === "reset-ipv6-security") { + handleResetSecurity(actionEl, true); + return; + } + + if (action === "remove-session") { + handleRemoveSession(actionEl); + return; + } + + if (action === "remove-other-sessions") { + handleRemoveOtherSessions(actionEl); + return; + } + + if (action === "open-ticket-detail") { + handleOpenTicketDetail(actionEl); + return; + } + + if (action === "open-knowledge-article") { + handleOpenKnowledgeArticle(actionEl); + return; + } + + if (action === "close-ticket-detail") { + state.selectedTicketId = null; + state.selectedTicket = null; + render(); + } + + if (action === "enable-ipv6") { + handleEnableIpv6(actionEl); + return; + } + + if (action === "sync-ipv6-password") { + handleSyncIpv6Password(actionEl); + return; + } + + if (action === "copy-node-info") { + handleCopyNodeInfo(actionEl.getAttribute("data-node-id")); + return; + } + + if (action === "set-mode") { + state.mode = actionEl.getAttribute("data-mode") || "login"; + render(); + return; + } + + if (action === "send-reset-code") { + var form = actionEl.closest("form"); + var email = form.querySelector("[name='email']").value; + if (!email) { + showMessage("闂囧倻鎲楅敓鏂ゆ嫹娴犲尅鎷烽摝鐐插珝鐨堟啰閿熸枻鎷?, "warning"); + return; + } + actionEl.disabled = true; + fetchJson("/api/v1/passport/comm/sendEmailVerify", { + method: "POST", + auth: false, + body: { email: email } + }).then(function () { + showMessage("閹惧顏堕敓鏂ゆ嫹閿熻姤鐡忛敓鐣屽悩閿熸枻鎷烽敓浠嬫腹閻熺噦鎷烽敓鎴掓唉閿熸枻鎷烽敓鏂ゆ嫹閾︾儑鎷?, "success"); + startCountdown(actionEl, 60); + }).catch(function (error) { + showMessage(error.message || "閿熺晫鎲伴敓鑺ユ嫟鐠滅櫢鎷烽敓鏂ゆ嫹娴犲啴鐓囬敓?, "error"); + actionEl.disabled = false; + }); + return; + } + } + + function startCountdown(button, seconds) { + var originalText = "閿熺晫鎲伴敓鑺ユ嫟鐠滅櫢鎷烽敓鏂ゆ嫹"; + button.disabled = true; + var current = seconds; + var timer = setInterval(function () { + if (current <= 0) { + clearInterval(timer); + button.innerText = originalText; + button.disabled = false; + return; + } + button.innerText = current + "s"; + current--; + }, 1000); + } + + async function handleCopyNodeInfo(nodeId) { + var server = (state.servers || []).concat(state.ipv6Servers || []).find(function (s) { + return String(s.id) === String(nodeId); + }); + if (!server) return; + + var isIpv6Node = (state.ipv6Servers || []).some(function (s) { + return String(s.id) === String(nodeId); + }); + + var sub = isIpv6Node ? state.ipv6Subscribe : state.subscribe; + var subUrl = (sub && sub.subscribe_url) || ""; + var targetName = (server.name || "").trim(); + var cacheKey = isIpv6Node ? "ipv6CachedSubNodes" : "cachedSubNodes"; + var storageKey = isIpv6Node ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__"; + + // Try to get link from subscription if possible + if (subUrl) { + try { + if (!state[cacheKey]) { + // Check if we have a manually pasted sub content in sessionStorage + var savedSub = window.sessionStorage.getItem(storageKey); + if (savedSub) { + console.log("Nebula: Using session-cached " + (isIpv6Node ? "IPv6 " : "") + "subscription content"); + state[cacheKey] = parseSubscriptionToLines(savedSub); + } else { + console.log("Nebula: Synchronizing " + (isIpv6Node ? "IPv6 " : "") + "subscription metadata..."); + state[cacheKey] = await fetchSubscriptionNodes(subUrl); + } + } + + if (state[cacheKey] && state[cacheKey].length) { + var link = findNodeLinkByName(state[cacheKey], targetName); + if (link) { + var clashYaml = linkToClash(link); + showNodeInfoModal(server, clashYaml, link); + return; + } + } + } catch (error) { + console.error("Nebula: Failed to parse subscription", error); + // If it looks like a CORS error (TypeError: Failed to fetch) + if (error instanceof TypeError) { + showCorsHelpModal(subUrl, nodeId, isIpv6Node); + return; + } + } + } + + // Fallback to text info if link not found or subscription failed + var tags = normalizeServerTags(server.tags); + var info = [ + "閿熸枻鎷烽敓鏂ゆ嫹濠娿劌顩? " + (server.name || "閿熷€熷Ъ绌扮洴閿熻姤鍝楅敓鏂ゆ嫹閿?), + "閿熸枻鎷烽敓鍊熸綏妞佸喛鎷? " + (server.type || "閿熷€熷Р娴?), + "閿熸枻鎷烽敓鏂ゆ嫹濠婎煉鎷? " + (server.rate || 1) + "x", + "閿熸枻鎷烽敓鏂ゆ嫹閸″棴鎷烽敓? " + (server.is_online ? "閿熻棄鍤遍悷? : "閾︽娊顦遍悷?), + tags.length ? "閿熸枻鎷烽敓鏂ゆ嫹閿熻棄鈧? " + tags.join(", ") : "" + ].filter(Boolean).join("\n"); + + copyText(info, "閹惧矁鍔婇敓鏂ゆ嫹閸″鎷烽敓钘夘劗閹跺拑鎷烽惍宀€绗愰敓鍊熷礉閿熶粙婀€閿濅緤鎷烽敓鑺ユ癄绌扮铂閺佽埖鐎奸敓浠嬪濠婃俺娅ㄦ0鍙夘煹閿?); + } + + 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 = [ + '

' + escapeHtml(server.name || "閿熸枻鎷烽敓浠嬫腹鐞涘矉鎷?) + '

', + '
', + '
' + + '閿熸枻鎷烽敓鍊熸綏妞佸喛鎷?/span>' + + '' + escapeHtml(server.type || "unknown").toUpperCase() + '' + + '
', + '
' + + '閾﹀繑鈷嶉敓鏂ゆ嫹濠婎煉鎷?/span>' + + '' + escapeHtml(String(server.rate || 1)) + 'x' + + '
', + '
', + '
', + '
' + escapeHtml(maskClashYaml(clashYaml)) + '
', + '
', + '' + ].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) 閾︽埌顥屽锛嗛敓閲戭棁纭锋嫹閸ゆ纭堕敓鐣屾В闂佈冨櫩閿熸垝渚涢敓鍊熸緥閻撻敮鏌嶉敓鐣屾啺閿熸枻鎷烽敓鏂ゆ嫹閿熻姤鑺遍拕顓ㄦ嫹閺嗘顬囨牭鎷烽敓鐣岀崝閿熷€燁潌绋峰暈閿熻棄纭堕柈濠囧閿熺晫鏀埈鐔镐淮閿熸枻鎷烽敓绲搉\n1. 閿熻棄顒粙顒勫鐨堚拤娲愁噯鎷烽崢鏉垮厳閿熻棄鍠楅敓浠嬫焻閿熷€熷暎闁垫繂澧遍敓鑺ユ寠閿熶粙婀€閿濅緤鎷烽敓鑺ユ癄绌扮铂\n2. 閹差厽绮撮敓鍊熸緥閻撻敮鏌嶉敓鑺ユ碂閸徰嶆嫹閿熸枻鎷烽敓钘夊悁閿熻姤鎳撻柍绶?. 妞佸牠銈粣鎷烽悽鍥ㄦ瀮閿熸枻鎷风€涚數矛閿熼噾鈻嶎垳鎶氶摝搴濅簼閺傛劧鎷烽崲銉ュ剻閹筋喚娼堥敓鍊熸将妞佸矉鎷?, + 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 = [ + '

閾﹀簼浜濋弬?' + (isIpv6 ? "IPv6 " : "") + '闂囧牞绲归敓鏂ゆ嫹閿熻姤宕?/h3>', + '

闂囧倻鎲伴敓浠嬫禌閹晪鎷烽敓鑺ョ泊閿熸枻鎷烽崱銉嫹闂囧牞绲归敓鐣屾绌戝仭鐟绢煉鎷烽敓鏂ゆ嫹闂婃劖鐖堕柕渚€濮栫殘鈷夋闯顕嗘嫹閿熸枻鎷烽梾鐐电埆閿熶粙濮栫粻闈╂嫹闂侇偓鎷烽敓鑺ユ寭閸♀槄鎷烽幗顔笺€呴敓鏂ゆ嫹

', + '', + '' + ].join(""); + + document.body.appendChild(overlay); + overlay.appendChild(modal); + + modal.querySelector("#nebula-paste-cancel").onclick = function() { + document.body.removeChild(overlay); + }; + + modal.querySelector("#nebula-paste-save").onclick = function() { + var content = modal.querySelector("#nebula-sub-paste").value.trim(); + if (content) { + window.sessionStorage.setItem(storageKey, content); + state[cacheKey] = parseSubscriptionToLines(content); + document.body.removeChild(overlay); + showMessage("闂囧牞绲归敓鏂ゆ嫹閸炶櫕妞忛敓钘夌《闁鎷风鍋ч敓钘夋閿?, "success"); + if (nodeId) handleCopyNodeInfo(nodeId); + } else { + showMessage("閿熸枻鎷烽幑鍡涘濠娾晪鎷烽柕婵堢暏瀵?, "error"); + } + }; + } + + async function fetchSubscriptionNodes(url) { + try { + var response = await fetch(url); + if (!response.ok) throw new Error("Network response was not ok"); + var text = await response.text(); + return parseSubscriptionToLines(text); + } catch (e) { + throw e; // Reraise to be caught by handleCopyNodeInfo for CORS check + } + } + + function parseSubscriptionToLines(text) { + var decoded = ""; + try { + decoded = utf8Base64Decode(text.trim()); + } catch (e) { + decoded = text; + } + return decoded.split(/\r?\n/).map(function(s) { return s.trim(); }).filter(Boolean); + } + + function findNodeLinkByName(links, name) { + if (!name) return null; + var target = name.trim(); + + console.log("Nebula: Searching for node '" + name + "' among " + links.length + " links (Strict Match)"); + + for (var i = 0; i < links.length; i++) { + var link = links[i].trim(); + var remark = ""; + + if (link.indexOf("#") !== -1) { + var parts = link.split("#"); + var rawRemark = parts[parts.length - 1]; + try { + remark = decodeURIComponent(rawRemark.replace(/\+/g, "%20")); + } catch (e) { + remark = rawRemark; + } + } else if (link.indexOf("vmess://") === 0) { + try { + var jsonStr = utf8Base64Decode(link.slice(8)); + var json = JSON.parse(jsonStr); + remark = json.ps || ""; + } catch (e) {} + } else if (link.indexOf("name:") !== -1) { + var yamlMatch = link.match(/name:\s*["']?([^"']+)["']?/); + if (yamlMatch) remark = yamlMatch[1]; + } + + // Strict exact match after trimming + if ((remark || "").trim() === target) { + return link; + } + } + + console.warn("Nebula: No exact match found for node '" + name + "'"); + return null; + } + + function maskClashYaml(yaml) { + if (!yaml) return ""; + return yaml + .replace(/server:\s*.+/g, "server: **********") + .replace(/password:\s*.+/g, "password: **********") + .replace(/uuid:\s*.+/g, "uuid: **********") + .replace(/public-key:\s*.+/g, "public-key: **********"); + } + + function linkToClash(link) { + if (!link) return ""; + + var remark = ""; + if (link.indexOf("#") !== -1) { + var parts = link.split("#"); + try { + remark = decodeURIComponent(parts[parts.length - 1].replace(/\+/g, "%20")); + } catch (e) { + remark = parts[parts.length - 1]; + } + } + + // Shadowsocks + if (link.indexOf("ss://") === 0) { + var body = link.split("#")[0].slice(5); + var parts = body.split("@"); + if (parts.length < 2) return link; + + var userinfo = parts[0]; + var serverinfo = parts[1]; + var decodedUser = ""; + try { + decodedUser = atob(userinfo.replace(/-/g, "+").replace(/_/g, "/")); + } catch (e) { + decodedUser = userinfo; + } + + var userParts = decodedUser.split(":"); + var serverParts = serverinfo.split(":"); + + return [ + "- name: \"" + (remark || "SS Node") + "\"", + " type: ss", + " server: " + serverParts[0], + " port: " + (serverParts[1] || 443), + " cipher: " + (userParts[0] || "aes-256-gcm"), + " password: " + (userParts[1] || ""), + " udp: true" + ].join("\n"); + } + + // VMess + if (link.indexOf("vmess://") === 0) { + try { + var json = JSON.parse(utf8Base64Decode(link.slice(8))); + var yaml = [ + "- name: \"" + (json.ps || remark || "VMess Node") + "\"", + " type: vmess", + " server: " + json.add, + " port: " + (json.port || 443), + " uuid: " + json.id, + " alterId: " + (json.aid || 0), + " cipher: auto", + " udp: true", + " tls: " + (json.tls === "tls" ? "true" : "false"), + " network: " + (json.net || "tcp"), + " servername: " + (json.sni || json.host || "") + ]; + if (json.net === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (json.path || "/")); + if (json.host) yaml.push(" headers:"); + if (json.host) yaml.push(" Host: " + json.host); + } + if (json.net === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (json.path || "")); + } + return yaml.join("\n"); + } catch (e) { + return link; + } + } + + // VLESS + if (link.indexOf("vless://") === 0) { + var body = link.split("#")[0].slice(8); + var main = body.split("?")[0]; + var query = body.split("?")[1] || ""; + var parts = main.split("@"); + var uuid = parts[0]; + var serverParts = (parts[1] || "").split(":"); + + var params = {}; + query.split("&").forEach(function (pair) { + var p = pair.split("="); + params[p[0]] = decodeURIComponent(p[1] || ""); + }); + + var yaml = [ + "- name: \"" + (remark || "VLESS Node") + "\"", + " type: vless", + " server: " + serverParts[0], + " port: " + (parseInt(serverParts[1]) || 443), + " uuid: " + uuid, + " udp: true", + " tls: " + (params.security !== "none" ? "true" : "false"), + " skip-cert-verify: true" + ]; + + if (params.sni) yaml.push(" servername: " + params.sni); + if (params.type) yaml.push(" network: " + params.type); + if (params.flow) yaml.push(" flow: " + params.flow); + if (params.fp) yaml.push(" client-fingerprint: " + params.fp); + if (params.security === "reality") { + yaml.push(" reality-opts:"); + if (params.pbk) yaml.push(" public-key: " + params.pbk); + if (params.sid) yaml.push(" short-id: " + params.sid); + } + if (params.type === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (params.path || "/")); + if (params.host) yaml.push(" headers:"); + if (params.host) yaml.push(" Host: " + params.host); + } + if (params.type === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (params.serviceName || "")); + } + + return yaml.join("\n"); + } + + // Trojan + if (link.indexOf("trojan://") === 0) { + var body = link.split("#")[0].slice(9); + var main = body.split("?")[0]; + var query = body.split("?")[1] || ""; + var parts = main.split("@"); + var password = parts[0]; + var serverParts = (parts[1] || "").split(":"); + + var params = {}; + query.split("&").forEach(function (pair) { + var p = pair.split("="); + params[p[0]] = decodeURIComponent(p[1] || ""); + }); + + var yaml = [ + "- name: \"" + (remark || "Trojan Node") + "\"", + " type: trojan", + " server: " + serverParts[0], + " port: " + (parseInt(serverParts[1]) || 443), + " password: " + password, + " udp: true", + " sni: " + (params.sni || params.peer || ""), + " skip-cert-verify: true" + ]; + + if (params.type === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (params.path || "/")); + if (params.host) yaml.push(" headers:"); + if (params.host) yaml.push(" Host: " + params.host); + } + if (params.type === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (params.serviceName || "")); + } + + return yaml.join("\n"); + } + + return link; + } + + function utf8Base64Decode(str) { + try { + var b64 = str.trim().replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + + var binary = atob(b64); + try { + if (typeof TextDecoder !== "undefined") { + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder("utf-8").decode(bytes); + } + + return decodeURIComponent(binary.split("").map(function (c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }).join("")); + } catch (e) { + return binary; + } + } catch (e) { + throw new Error("Invalid base64 content: " + e.message); + } + } + + function onInput(event) { + var target = event.target; + if (target.matches("[data-action='search-servers']")) { + state.serverSearch = target.value; + state.nodePage = 1; + render(); + + var input = document.querySelector("[data-action='search-servers']"); + if (input) { + input.focus(); + input.setSelectionRange(target.value.length, target.value.length); + } + } + } + + async function handleEnableIpv6(actionEl) { + actionEl.disabled = true; + try { + var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" }); + var payload = unwrap(response) || {}; + if (payload.auth_data) { + saveIpv6Token(payload.auth_data); + state.ipv6AuthToken = getStoredIpv6Token(); + } + showMessage("IPv6 闂囧牞绲归敓鑺ユ嫲閼达綇鎷烽敓鍊熷礉閿熺晫鏀敓浠嬪Μ閿熺晫鎲绘?..", "success"); + // Try to login to IPv6 account if possible, or just refresh dashboard + // Since we don't have the password here (state doesn't keep it), + // the user might need to sync password first or we assume it was synced during creation. + await loadDashboard(true); + render(); + } catch (error) { + showMessage(error.message || "閹炬﹫鎷烽敓鍊熷殲娴犲啴鐓囬敓?, "error"); + } finally { + actionEl.disabled = false; + } + } + + async function handleSyncIpv6Password(actionEl) { + actionEl.disabled = true; + try { + await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" }); + showMessage("閹炬牭鎷烽敓鑺ユ嫲閼达綇鎷烽悽鍥︾胺閿?IPv6 闂婃劘顢戦敓?, "success"); + } catch (error) { + showMessage(error.message || "閿熻棄纭堕柈濠冨暢濮婎垱褰?, "error"); + } finally { + actionEl.disabled = false; + } + } + + async function loadUplinkMetric() { + if (state.authToken) { + return; + } + var baseUrl = getMetricsBaseUrl(); + if (!baseUrl) { + return; + } + try { + var response = await fetch(baseUrl + "/api/metrics/overview", { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest" + } + }); + var payload = await response.json(); + var network = (payload && payload.network) || {}; + var tx = Number(network.tx || 0); + var total = tx; + if (total > 0) { + state.uplinkMbps = (total * 8) / 1000000; + var el = document.getElementById("nebula-hero-metric-stack"); + if (el) { + el.innerHTML = renderUplinkMetricStack(); + } else if (!state.authToken || !state.user) { + render(); + } + } + } catch (error) { + state.uplinkMbps = null; + } + } + + function onSubmit(event) { + var form = event.target; + if (!form.matches("[data-form]")) { + return; + } + + event.preventDefault(); + var formType = form.getAttribute("data-form"); + var data = Object.fromEntries(new FormData(form).entries()); + var submitButton = form.querySelector("[type='submit']"); + + if (submitButton) { + submitButton.disabled = true; + } + + if (formType === "create-ticket") { + fetchJson("/api/v1/user/ticket/save", { + method: "POST", + body: { + subject: data.subject || "", + level: data.level || "0", + message: data.message || "" + } + }).then(function () { + showMessage("閹惧奔绨烽敓鑺ユ嫲閼达綇鎷烽幘鎲嬫嫹", "success"); + state.selectedTicketId = null; + state.selectedTicket = null; + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "閹惧奔绨烽敓鏂ゆ嫹棣ユ殥闁絾鍟冲顖涘絿", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "real-name-verification") { + fetchJson("/api/v1/user/real-name-verification/submit", { + method: "POST", + body: { + real_name: data.real_name || "", + identity_no: data.identity_no || "" + } + }).then(function (response) { + var payload = unwrap(response) || {}; + var verification = payload.verification || payload; + state.realNameVerification = verification; + showMessage( + verification && verification.status === "approved" + ? "閹芥歇顕嗘嫹闂囧牊鏌熼敓鑺ユ嫲閼淬倧鎷烽張鐑囨嫹閿熸枻鎷? + : "闂囧牊鏌熼敓浠嬫緰閳儻鎷烽幘宀冨姅閿熶粙鍩堝鐧告嫹闂囧倻鎲洪敓鑺ユ毇閿熻姤浠敓鍊熲敆閿熸枻鎷?, + "success" + ); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "闂囧牊鏌熼敓鏂ゆ嫹绌戝仭濠曡鲸鍟冲顖涘絿", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "change-password") { + if (data.new_password !== data.confirm_password) { + showMessage("闁垫繃鏋熷ú濠氣拤閺嬪骏鎷烽敓鏂ゆ嫹姒樺灚鎸呴敓鏂ゆ嫹闁垫繃铏庨敓鏂ゆ嫹閿?, "error"); + if (submitButton) submitButton.disabled = false; + return; + } + fetchJson("/api/v1/user/changePassword", { + method: "POST", + body: { + old_password: data.old_password || "", + new_password: data.new_password || "" + } + }).then(function () { + showMessage("閹炬牭鎷烽敓鑺ユ嫲閼存牞鈧煉鎷风亸宥忔嫹閹捐京鐣¢幃鍜冩嫹閿?IPv6 閿熸枻鎷烽敓鏂ゆ嫹瀹勬洟鍎曢敓钘夋殠閿熸枻鎷烽敓?, "success"); + form.reset(); + }).catch(function (error) { + showMessage(error.message || "閹炬牭鎷烽敓浠嬫緰閺嶇鍤庨幉顓燁潽閹?, "error"); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "forget-password") { + fetchJson("/api/v1/passport/auth/forget", { + method: "POST", + auth: false, + body: { + email: data.email, + email_code: data.email_code, + password: data.password + } + }).then(function () { + showMessage("閹炬牭鎷烽敓鑺ユ嫲閼淬倧鎷烽摝鍨啂閿熶粙娓归悷姘虫饯閿熻棄鍠楁鍨寘閿熸枻鎷烽敓浠嬵槰閿?, "success"); + state.mode = "login"; + render(); + }).catch(function (error) { + showMessage(error.message || "閿熻姤鑺遍拕顓熷暢濮婎垱褰?, "error"); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + fetchJson(formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login", { + method: "POST", + auth: false, + body: data + }).then(function (response) { + var payload = unwrap(response); + if (!payload || !payload.auth_data) { + throw new Error("Authentication succeeded but token payload is missing"); + } + saveToken(payload.auth_data); + state.authToken = getStoredToken(); + + // Try IPv6 login/register if email/password available + if (data.email && data.password) { + var ipv6Email = data.email.replace("@", "-ipv6@"); + var ipv6Action = formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login"; + + fetchJson(ipv6Action, { + method: "POST", + auth: false, + body: Object.assign({}, data, { email: ipv6Email }) + }).then(function (ipv6Response) { + var ipv6Payload = unwrap(ipv6Response); + if (ipv6Payload && ipv6Payload.auth_data) { + saveIpv6Token(ipv6Payload.auth_data); + state.ipv6AuthToken = getStoredIpv6Token(); + } + }).catch(function () { + // Ignore IPv6 errors as it might not be enabled yet or fail + }).finally(function() { + loadDashboard(true).then(render); + }); + } + + showMessage(formType === "register" ? "Account created" : "Signed in", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to continue", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + } + + function handleResetSecurity(actionEl, isIpv6) { + showNebulaModal({ + title: "閹芥澘灏庨敓鏂ゆ嫹闂夋拝鎷?, + content: "閾︽牗顢呴敓浠嬫焼閿熸枻鎷烽摝鍨墯閹儻鎷烽敓鍊熺崷閿熸枻鎷烽敓钘夋缂冮潻鎷烽摝鍨敃鐟炬﹫鎷烽敓鏂ゆ嫹閸ゆ鎷烽敓鏂ゆ嫹缁佸鎷烽棁鍫倒閿熸枻鎷烽崯锝忔嫹閹剧媴鎷锋禒鍐挎嫹閿熸枻鎷烽梿绛愶紦鍥锋嫹闂侇剨鎷烽敓鏂ゆ嫹閸熸宸╅敓鐣岊攼閸嶅函鎷峰銊ㄦ暘闂囧牞绲归敓鏂ゆ嫹閿?, + confirmText: "閾︽牗鐗氶幁鏇嫹濠娿劏鏁?, + onConfirm: function () { + actionEl.disabled = true; + fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) { + var subscribeUrl = unwrap(response); + var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; + if (sub && subscribeUrl) { + sub.subscribe_url = subscribeUrl; + } + showMessage((isIpv6 ? "IPv6 " : "") + "闂囧牞绲归敓浠嬫閺傘倧鎷烽幘宀冨妺閿熷€熸緥閿?, "success"); + render(); + }).catch(function (error) { + showMessage(error.message || "閿熻姤鑺遍拕顓熷暢濮婎垱褰?, "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + }); + } + + function handleRemoveSession(actionEl) { + var sessionId = actionEl.getAttribute("data-session-id"); + if (!sessionId) { + return; + } + + actionEl.disabled = true; + fetchJson("/api/v1/user/removeActiveSession", { + method: "POST", + body: { session_id: sessionId } + }).then(function () { + showMessage("Session revoked", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to revoke session", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleRemoveOtherSessions(actionEl) { + var sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter(function (session) { + return !session.is_current; + }); + + if (!sessions.length) { + showMessage("No other sessions to sign out.", "success"); + render(); + return; + } + + actionEl.disabled = true; + Promise.all(sessions.map(function (session) { + return fetchJson("/api/v1/user/removeActiveSession", { + method: "POST", + body: { session_id: session.id } + }); + })).then(function () { + showMessage("Signed out all other sessions.", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to sign out other sessions", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleOpenTicketDetail(actionEl) { + var ticketId = actionEl.getAttribute("data-ticket-id"); + if (!ticketId) { + return; + } + actionEl.disabled = true; + fetchJson("/api/v1/user/ticket/fetch?id=" + encodeURIComponent(ticketId), { method: "GET" }).then(function (response) { + state.selectedTicketId = ticketId; + state.selectedTicket = unwrap(response); + render(); + }).catch(function (error) { + showMessage(error.message || "閹惧奔绨烽敓浠嬫腹鐞涘矉鎷烽敓濮愮毠閾﹀憡鍟冲顖涘絿", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleOpenKnowledgeArticle(actionEl) { + var articleId = actionEl.getAttribute("data-article-id"); + if (!articleId) { + return; + } + + var article = findKnowledgeArticleById(articleId); + if (!article) { + showMessage("Knowledge article not found", "error"); + return; + } + + showKnowledgeArticleModal(article); + } + + var currentLayout = null; // "loading", "login", "dashboard" + + function render() { + if (state.loading) { + return; + } + + var isLoggedIn = !!(state.authToken && state.user); + var targetLayout = isLoggedIn ? "dashboard" : "login"; + + // If layout type changed (e.g. Login -> Dashboard), do a full shell render + if (currentLayout !== targetLayout) { + currentLayout = targetLayout; + app.innerHTML = isLoggedIn ? renderDashboard() : renderAuth(); + hideLoader(); + return; + } + + // If already in Dashboard, only update the content area and sidebar state + if (isLoggedIn) { + var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); + var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; + var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); + var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; + var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; + var overview = state.sessionOverview || {}; + + var contentArea = document.querySelector(".dashboard-main"); + if (contentArea) { + contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); + } + + var sidebarContainer = document.querySelector(".dashboard-shell"); + if (sidebarContainer) { + var sidebar = sidebarContainer.querySelector(".dashboard-sidebar"); + if (sidebar) { + sidebar.innerHTML = renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, true); + } + } + } else { + // For auth page, simple re-render is fine + app.innerHTML = renderAuth(); + } + + hideLoader(); + } + + function renderAuth() { + return [ + '
', + renderTopbar(false), + '
', + '
', + '
' + escapeHtml(theme.description || "") + "
", + '
' + renderUplinkMetricStack() + '
', + (theme.config && theme.config.slogan) ? '

' + escapeHtml(theme.config.slogan) + "

" : "", + "
", + '
', + (theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [ + '
', + tabButton("login", "閿熶粙顦ㄩ敓?, state.mode === "login"), + (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "閻︽粌鍚€閿?, state.mode === "register") : "", + tabButton("forget-password", "閿熻姤娉敓鑺ユ寘閿熸枻鎷?, state.mode === "forget-password"), + "
" + ].join("") : [ + '
', + tabButton("login", "閿熶粙顦ㄩ敓?, state.mode === "login"), + tabButton("forget-password", "閿熻姤娉敓鑺ユ寘閿熸枻鎷?, state.mode === "forget-password"), + "
" + ].join(""), + renderAuthPanelHeading(), + renderAuthForm(), + "
", + "
", + renderRecordFooter(), + "
" + ].join(""); + } + + function renderDashboard() { + var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); + var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; + var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); + var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; + var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; + var overview = state.sessionOverview || {}; + + return renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); + } + + function renderTopbar(isLoggedIn) { + return [ + '
', + '
', + isLoggedIn ? '' : '', + renderBrandMark(), + '

' + escapeHtml(theme.title || "Nebula") + "

", + "
", + '
', + renderThemeToggle(), + isLoggedIn + ? '
", + "
" + ].join(""); + } + + function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { + return [ + '
', + renderTopbar(true), + '', + '
', + renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview), + '
', + renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview), + '
', + '
', + renderRecordFooter(), + '
' + ].join(""); + } + + function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { + if (state.currentRoute === "access-history") { + return [ + '
', + renderSessionSection(overview.sessions || []), + renderIpSection(overview.online_ips || []), + '
' + ].join(""); + } + if (state.currentRoute === "nodes") { + return [ + '
', + renderSubscribeSection("span-12 section-card--overview"), + renderServerSection(state.servers || []), + '
' + ].join(""); + } + if (state.currentRoute === "ipv6-nodes") { + return [ + '
', + renderIpv6SubscribeSection("span-12 section-card--overview"), + renderServerSection(state.ipv6Servers || [], true), + '
' + ].join(""); + } + if (state.currentRoute === "tickets") { + return [ + '
', + renderTicketComposer(), + renderTicketSection(state.tickets || []), + renderTicketDetailSection(state.selectedTicket), + '
' + ].join(""); + } + if (state.currentRoute === "real-name") { + return [ + '
', + renderRealNameVerificationPanel(), + '
' + ].join(""); + } + if (state.currentRoute === "security") { + return [ + '
', + renderSecuritySection(), + '
' + ].join(""); + } + + var html = [ + '
', + 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"), + '
' + ].join(""); + return html; + } + + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { + return [ + '
', + '
', + + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { + return [ + '
', + '
', + 'Subscription', + "

" + escapeHtml(theme.title || "Nebula") + "

", + "

" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " 缁?" + escapeHtml((state.user && state.user.email) || "") + "

", + '
', + dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"), + dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"), + dashboardStat(String(overview.active_session_count || 0), "Stored sessions"), + '
', + '
', + '', + '
', + renderSubscribeSection(), + '
' + ].join(""); + } + + function renderAccessSnapshot(overview) { + return [ + '
', + renderAccessSnapshotCard(overview), + renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview), + '
' + ].join(""); + } + + function renderAccessSnapshotCard(overview, extraClass) { + extraClass = extraClass || "span-7"; + var ipv6Overview = state.ipv6SessionOverview; + var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : ""); + return [ + '
', diff --git a/frontend/theme/Nebula/assets/app.js.restored b/frontend/theme/Nebula/assets/app.js.restored new file mode 100644 index 0000000..662e9d3 --- /dev/null +++ b/frontend/theme/Nebula/assets/app.js.restored @@ -0,0 +1,2942 @@ +(function () { + "use strict"; + + var theme = window.NEBULA_THEME || {}; + var app = document.getElementById("app"); + var loader = document.getElementById("nebula-loader"); + var themeMediaQuery = getThemeMediaQuery(); + + if (!app) { + return; + } + + var state = { + mode: "login", + loading: true, + message: "", + messageType: "", + themePreference: getStoredThemePreference(), + themeMode: "dark", + uplinkMbps: null, + currentRoute: getCurrentRoute(), + nodePage: 1, + localIp: null, + reportedIps: [], + authToken: getStoredToken(), + user: null, + subscribe: null, + stats: null, + knowledge: [], + tickets: [], + selectedTicketId: null, + selectedTicket: null, + servers: [], + ipv6AuthToken: getStoredIpv6Token(), + ipv6User: null, + ipv6Subscribe: null, + ipv6Eligibility: null, + ipv6Servers: [], + sessionOverview: null, + ipv6SessionOverview: null, + realNameVerification: null, + appConfig: null, + isSidebarOpen: false, + serverSearch: "", + cachedSubNodes: null, + ipv6CachedSubNodes: null + }; + + init(); + + async function init() { + applyThemeMode(); + applyCustomBackground(); + loadUplinkMetric(); + window.setInterval(loadUplinkMetric, 5000); + + try { + var verify = getVerifyToken(); + if (verify) { + var verifyResponse = await fetchJson("/api/v1/passport/auth/token2Login?verify=" + encodeURIComponent(verify), { + method: "GET", + auth: false + }); + var verifyPayload = unwrap(verifyResponse); + if (verifyPayload && verifyPayload.auth_data) { + saveToken(verifyPayload.auth_data); + clearVerifyToken(); + state.authToken = getStoredToken(); + } + } + } catch (error) { + showMessage(error.message || "Quick login failed", "error"); + } + + if (state.authToken) { + var loaded = await loadDashboard(); + if (!loaded) { + state.loading = false; + } + } else { + state.loading = false; + } + + render(); + app.addEventListener("click", onClick); + app.addEventListener("input", onInput); + app.addEventListener("submit", onSubmit); + window.addEventListener("hashchange", onRouteChange); + bindSystemThemeListener(); + } + + async function loadDashboard(silent) { + if (!state.authToken) { + state.loading = false; + return false; + } + + state.loading = !silent; + if (!silent) { + render(); + } + + try { + var results = await Promise.all([ + fetchJson("/api/v1/user/info", { method: "GET" }), + fetchJson("/api/v1/user/getSubscribe", { method: "GET" }), + fetchJson("/api/v1/user/getStat", { method: "GET" }), + fetchJson("/api/v1/user/server/fetch", { method: "GET" }), + fetchJson("/api/v1/user/ticket/fetch", { method: "GET" }), + fetchSessionOverview(), + fetchJson("/api/v1/user/comm/config", { method: "GET" }), + fetchRealNameVerificationStatus(), + fetchJson("/api/v1/user/user-add-ipv6-subscription/check", { method: "GET" }) + ]); + + state.user = unwrap(results[0]); + state.subscribe = unwrap(results[1]); + state.stats = unwrap(results[2]); + state.servers = unwrap(results[3]) || []; + state.knowledge = []; + state.tickets = unwrap(results[4]) || []; + state.sessionOverview = results[5]; + state.appConfig = unwrap(results[6]) || {}; + state.realNameVerification = results[7] || null; + state.ipv6Eligibility = unwrap(results[8]) || null; + + if (state.ipv6AuthToken) { + try { + var ipv6Results = await Promise.all([ + fetchJson("/api/v1/user/info", { method: "GET", ipv6: true }), + fetchJson("/api/v1/user/getSubscribe", { method: "GET", ipv6: true }), + fetchJson("/api/v1/user/server/fetch", { method: "GET", ipv6: true }), + fetchSessionOverview(true) + ]); + state.ipv6User = unwrap(ipv6Results[0]); + state.ipv6Subscribe = unwrap(ipv6Results[1]); + state.ipv6Servers = unwrap(ipv6Results[2]) || []; + state.ipv6SessionOverview = ipv6Results[3]; + } catch (e) { + if (handleIpv6AuthFailure(e)) { + console.warn("Nebula: cleared stale IPv6 session after unauthorized response"); + } + console.error("Failed to load IPv6 dashboard data", e); + } + } + + state.nodePage = 1; + state.loading = false; + return true; + } catch (error) { + state.loading = false; + if (error.status === 401 || error.status === 403) { + var hadSession = Boolean(state.authToken); + clearToken(); + resetDashboard(); + showMessage(hadSession ? "Session expired. Please sign in again." : "", hadSession ? "error" : ""); + return false; + } + showMessage(error.message || "Failed to load dashboard", "error"); + return false; + } + } + + async function fetchSessionOverview(isIpv6) { + try { + var response = await fetchJson("/api/v1/user/user-online-devices/get-ip", { method: "GET", ipv6: !!isIpv6 }); + var ipPayload = unwrap(response) || {}; + + var reportedIps = ipPayload.ips || []; + if (!isIpv6) { + state.reportedIps = reportedIps; + } + + var overview = ipPayload.session_overview || { sessions: [], online_ips: [] }; + if (!isIpv6) { + overview.online_ips = state.reportedIps; + } + + return buildSessionOverview(overview.sessions, overview, !!isIpv6); + } catch (error) { + if (isIpv6 && handleIpv6AuthFailure(error)) { + return null; + } + console.error("Nebula: Failed to synchronize online status", error); + // Fallback for sessions if plugin fails + try { + var sessionsFallback = unwrap(await fetchJson("/api/v1/user/getActiveSession", { method: "GET", ipv6: !!isIpv6 })) || []; + return buildSessionOverview(sessionsFallback, {}, !!isIpv6); + } catch (e) { + if (isIpv6 && handleIpv6AuthFailure(e)) { + return null; + } + return buildSessionOverview([], {}, !!isIpv6); + } + } + } + + async function fetchRealNameVerificationStatus() { + try { + var response = await fetchJson("/api/v1/user/real-name-verification/status", { method: "GET" }); + var payload = unwrap(response) || {}; + return Object.assign({ + enabled: true, + status: "unverified", + status_label: "锟借娋鎭曢渹锟?, + can_submit: true, + real_name: "", + identity_no_masked: "", + notice: "", + reject_reason: "", + submitted_at: null, + reviewed_at: null + }, payload); + } catch (error) { + return { + enabled: false, + status: "unavailable", + status_label: "Unavailable", + can_submit: false, + real_name: "", + identity_no_masked: "", + notice: "", + reject_reason: "", + submitted_at: null, + reviewed_at: null + }; + } + } + + async function fetchJson(url, options) { + options = options || {}; + var headers = { + "Content-Type": "application/json", + "X-Requested-With": "XMLHttpRequest" + }; + + if (options.auth !== false) { + var token = options.ipv6 ? (state.ipv6AuthToken || getStoredIpv6Token()) : (state.authToken || getStoredToken()); + if (token) { + headers.Authorization = normalizeAuthHeader(token); + } + } + + var response = await fetch(url, { + method: options.method || "GET", + headers: headers, + credentials: "same-origin", + body: options.body ? JSON.stringify(options.body) : undefined + }); + + var payload = null; + try { + payload = await response.json(); + } catch (error) { + payload = null; + } + + if (!response.ok) { + var message = payload && (payload.message || payload.error) || "Request failed"; + var err = new Error(message); + err.status = response.status; + throw err; + } + + return payload; + } + + function unwrap(payload) { + if (!payload) { + return null; + } + if (typeof payload.data !== "undefined") { + return payload.data; + } + if (typeof payload.total !== "undefined" && Array.isArray(payload.data)) { + return payload.data; + } + return payload; + } + + function onClick(event) { + if (state.isSidebarOpen) { + var sidebar = event.target.closest(".dashboard-sidebar"); + var toggleBtn = event.target.closest("[data-action='toggle-sidebar']"); + if (!sidebar && !toggleBtn) { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + var sidebarEl = document.querySelector(".dashboard-sidebar"); + var overlayEl = document.querySelector(".sidebar-overlay"); + if (sidebarEl) sidebarEl.classList.remove("is-open"); + if (overlayEl) overlayEl.classList.remove("is-visible"); + } + } + + var actionEl = event.target.closest("[data-action]"); + if (!actionEl) return; + + var action = actionEl.getAttribute("data-action"); + + if (action === "switch-mode") { + state.mode = actionEl.getAttribute("data-mode") || "login"; + showMessage("", ""); + render(); + return; + } + + if (action === "navigate") { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + setCurrentRoute(actionEl.getAttribute("data-route") || "overview"); + return; + } + + if (action === "toggle-sidebar") { + state.isSidebarOpen = !state.isSidebarOpen; + document.documentElement.dataset.sidebarOpen = String(state.isSidebarOpen); + var sidebar = document.querySelector(".dashboard-sidebar"); + var overlay = document.querySelector(".sidebar-overlay"); + if (sidebar) sidebar.classList.toggle("is-open", state.isSidebarOpen); + if (overlay) overlay.classList.toggle("is-visible", state.isSidebarOpen); + return; + } + + if (action === "close-sidebar") { + state.isSidebarOpen = false; + document.documentElement.dataset.sidebarOpen = "false"; + var sidebar = document.querySelector(".dashboard-sidebar"); + var overlay = document.querySelector(".sidebar-overlay"); + if (sidebar) sidebar.classList.remove("is-open"); + if (overlay) overlay.classList.remove("is-visible"); + return; + } + + if (action === "toggle-theme-mode") { + toggleThemeMode(); + return; + } + + if (action === "change-node-page") { + setNodePage(Number(actionEl.getAttribute("data-page") || 1)); + return; + } + + if (action === "logout") { + clearToken(); + clearIpv6Token(); + resetDashboard(); + render(); + return; + } + + if (action === "refresh-dashboard") { + loadDashboard(true).then(render); + return; + } + + if (action === "copy-subscribe") { + copyText((state.subscribe && state.subscribe.subscribe_url) || ""); + return; + } + + if (action === "copy-ipv6-subscribe") { + copyText((state.ipv6Subscribe && state.ipv6Subscribe.subscribe_url) || ""); + return; + } + + if (action === "copy-text") { + copyText(actionEl.getAttribute("data-value") || ""); + return; + } + + if (action === "reset-security") { + handleResetSecurity(actionEl); + return; + } + + if (action === "reset-ipv6-security") { + handleResetSecurity(actionEl, true); + return; + } + + if (action === "remove-session") { + handleRemoveSession(actionEl); + return; + } + + if (action === "remove-other-sessions") { + handleRemoveOtherSessions(actionEl); + return; + } + + if (action === "open-ticket-detail") { + handleOpenTicketDetail(actionEl); + return; + } + + if (action === "open-knowledge-article") { + handleOpenKnowledgeArticle(actionEl); + return; + } + + if (action === "close-ticket-detail") { + state.selectedTicketId = null; + state.selectedTicket = null; + render(); + } + + if (action === "enable-ipv6") { + handleEnableIpv6(actionEl); + return; + } + + if (action === "sync-ipv6-password") { + handleSyncIpv6Password(actionEl); + return; + } + + if (action === "copy-node-info") { + handleCopyNodeInfo(actionEl.getAttribute("data-node-id")); + return; + } + + if (action === "set-mode") { + state.mode = actionEl.getAttribute("data-mode") || "login"; + render(); + return; + } + + if (action === "send-reset-code") { + var form = actionEl.closest("form"); + var email = form.querySelector("[name='email']").value; + if (!email) { + showMessage("闇傜憗锟斤拷浠匡拷铦炲嫍皈憪锟斤拷", "warning"); + return; + } + actionEl.disabled = true; + fetchJson("/api/v1/passport/comm/sendEmailVerify", { + method: "POST", + auth: false, + body: { email: email } + }).then(function () { + showMessage("鎾夎锟斤拷锟芥瓏锟界吘锟斤拷锟介渹鐟燂拷锟戒氦锟斤拷锟斤拷铦烇拷", "success"); + startCountdown(actionEl, 60); + }).catch(function (error) { + showMessage(error.message || "锟界憰锟芥拤璜癸拷锟斤拷浠冮煇锟?, "error"); + actionEl.disabled = false; + }); + return; + } + } + + function startCountdown(button, seconds) { + var originalText = "锟界憰锟芥拤璜癸拷锟斤拷"; + button.disabled = true; + var current = seconds; + var timer = setInterval(function () { + if (current <= 0) { + clearInterval(timer); + button.innerText = originalText; + button.disabled = false; + return; + } + button.innerText = current + "s"; + current--; + }, 1000); + } + + async function handleCopyNodeInfo(nodeId) { + var server = (state.servers || []).concat(state.ipv6Servers || []).find(function (s) { + return String(s.id) === String(nodeId); + }); + if (!server) return; + + var isIpv6Node = (state.ipv6Servers || []).some(function (s) { + return String(s.id) === String(nodeId); + }); + + var sub = isIpv6Node ? state.ipv6Subscribe : state.subscribe; + var subUrl = (sub && sub.subscribe_url) || ""; + var targetName = (server.name || "").trim(); + var cacheKey = isIpv6Node ? "ipv6CachedSubNodes" : "cachedSubNodes"; + var storageKey = isIpv6Node ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__"; + + // Try to get link from subscription if possible + if (subUrl) { + try { + if (!state[cacheKey]) { + // Check if we have a manually pasted sub content in sessionStorage + var savedSub = window.sessionStorage.getItem(storageKey); + if (savedSub) { + console.log("Nebula: Using session-cached " + (isIpv6Node ? "IPv6 " : "") + "subscription content"); + state[cacheKey] = parseSubscriptionToLines(savedSub); + } else { + console.log("Nebula: Synchronizing " + (isIpv6Node ? "IPv6 " : "") + "subscription metadata..."); + state[cacheKey] = await fetchSubscriptionNodes(subUrl); + } + } + + if (state[cacheKey] && state[cacheKey].length) { + var link = findNodeLinkByName(state[cacheKey], targetName); + if (link) { + var clashYaml = linkToClash(link); + showNodeInfoModal(server, clashYaml, link); + return; + } + } + } catch (error) { + console.error("Nebula: Failed to parse subscription", error); + // If it looks like a CORS error (TypeError: Failed to fetch) + if (error instanceof TypeError) { + showCorsHelpModal(subUrl, nodeId, isIpv6Node); + return; + } + } + } + + // Fallback to text info if link not found or subscription failed + var tags = normalizeServerTags(server.tags); + var info = [ + "锟斤拷锟斤拷婊ㄥ: " + (server.name || "锟借姼穰盯锟芥哗锟斤拷锟?), + "锟斤拷锟借潗椁冿拷: " + (server.type || "锟借姲浒?), + "锟斤拷锟斤拷婊拷: " + (server.rate || 1) + "x", + "锟斤拷锟斤拷鍡嗭拷锟? " + (server.is_online ? "锟藉嚱鐟? : "铦抽鐟?), + tags.length ? "锟斤拷锟斤拷锟藉€? " + tags.join(", ") : "" + ].filter(Boolean).join("\n"); + + copyText(info, "鎾岃劊锟斤拷鍡夛拷锟藉鎶咃拷鐮岀笐锟借崝锟介湀锝侊拷锟芥毠穰粬鏁舵瀼锟介姖婊氳櫨棰叉锟?); + } + + function showNodeInfoModal(server, clashYaml, standardLink) { + var overlay = document.createElement("div"); + overlay.className = "nebula-modal-overlay"; + + var modal = document.createElement("div"); + modal.className = "nebula-modal stack"; + modal.style.maxWidth = "600px"; + modal.style.width = "min(540px, 100%)"; + + modal.innerHTML = [ + '

' + escapeHtml(server.name || "锟斤拷锟介渹琛岋拷") + '

', + '
', + '
' + + '锟斤拷锟借潗椁冿拷' + + '' + escapeHtml(server.type || "unknown").toUpperCase() + '' + + '
', + '
' + + '铦忔⒍锟斤拷婊拷' + + '' + escapeHtml(String(server.rate || 1)) + 'x' + + '
', + '
', + '
', + '
' + escapeHtml(maskClashYaml(clashYaml)) + '
', + '
', + '' + ].join(""); + + document.body.appendChild(overlay); + overlay.appendChild(modal); + + modal.querySelector("#node-info-close").onclick = function() { + document.body.removeChild(overlay); + }; + + modal.querySelector("#node-info-copy-link").onclick = function() { + copyText(standardLink, "锟斤拷锟斤拷婊ㄨ敪锟芥毠穰粬鎾岃劊锟斤拷锟?); + document.body.removeChild(overlay); + }; + + modal.querySelector("#node-info-copy-clash").onclick = function() { + copyText(clashYaml, "Clash 锟芥花钄拰鑴o拷锟斤拷"); + document.body.removeChild(overlay); + }; + + overlay.onclick = function(e) { + if (e.target === overlay) document.body.removeChild(overlay); + }; + } + + function showCorsHelpModal(subUrl, nodeId, isIpv6) { + var title = (isIpv6 ? "IPv6 " : "") + "锟藉硶閮婇湀锝侊拷锟藉敵鏃?; + showNebulaModal({ + title: title, + content: "锟芥浌锟界槡璁涳拷锟藉埢妤婏拷锟?(CORS) 铦戰夎&锟金硷拷鍤楀硶锟界槣闁у噿锟戒供锟借澋鐓锯柍锟界憰锟斤拷锟斤拷锟芥花钄拷鏆桂栵拷锟界獔锟借稷啞锟藉硶閮婇姖锟界攬鈭熸仴锟斤拷锟絓n\n1. 锟藉绋姖皈⒉洳拷鍘板兗锟藉喗锟介柅锟借啣閵濆墱锟芥挊锟介湀锝侊拷锟芥毠穰粬\n2. 鎲粴锟借澋鐓锯柍锟芥泟鍏э拷锟斤拷锟藉吀锟芥懓閳緉3. 椁堭オ滐拷鐢囨枃锟斤拷瀛电ì锟金▍抚铦庝亝鏂愶拷鍢ュ儙鎽潈锟借潫椁岋拷", + confirmText: "锟芥瀼锟介湀锝侊拷锟芥毠穰粬", + cancelText: "锟金革拷锟借稷啞铦庝亝鏂?, + onConfirm: function() { + window.open(subUrl, "_blank"); + showMessage("闇堬絹锟芥拰鑴i妬锟藉敵锟借潙鏆糕柍锟芥瀼锟藉殫璜圭獔鎲粴锟斤拷鍡咃拷鎽帮拷", "info"); + }, + onCancel: function() { + showManualPasteModal(nodeId, isIpv6); + } + }); + } + + function showManualPasteModal(nodeId, isIpv6) { + var overlay = document.createElement("div"); + overlay.className = "nebula-modal-overlay"; + + var modal = document.createElement("div"); + modal.className = "nebula-modal stack"; + modal.style.maxWidth = "550px"; + + var storageKey = isIpv6 ? "__nebula_ipv6_sub_content__" : "__nebula_sub_content__"; + var cacheKey = isIpv6 ? "ipv6CachedSubNodes" : "cachedSubNodes"; + + modal.innerHTML = [ + '

铦庝亝鏂?' + (isIpv6 ? "IPv6 " : "") + '闇堬絹锟斤拷锟芥崋

', + '

闇傜憰锟介浛鎯╋拷锟芥粴锟斤拷鍡ワ拷闇堬絹锟界殲穑偡瑾拷锟斤拷闊愭父閵侀姖皈⒉洳拷锟斤拷闅炵爫锟介姖绠革拷闁拷锟芥挓鍡★拷鎽々锟斤拷

', + '', + '' + ].join(""); + + document.body.appendChild(overlay); + overlay.appendChild(modal); + + modal.querySelector("#nebula-paste-cancel").onclick = function() { + document.body.removeChild(overlay); + }; + + modal.querySelector("#nebula-paste-save").onclick = function() { + var content = modal.querySelector("#nebula-sub-paste").value.trim(); + if (content) { + window.sessionStorage.setItem(storageKey, content); + state[cacheKey] = parseSubscriptionToLines(content); + document.body.removeChild(overlay); + showMessage("闇堬絹锟斤拷鍞虫椏锟藉硶閮婏拷穑偧锟藉殫锟?, "success"); + if (nodeId) handleCopyNodeInfo(nodeId); + } else { + showMessage("锟斤拷鎹嗛姖婊╋拷閵濈畤寰?, "error"); + } + }; + } + + async function fetchSubscriptionNodes(url) { + try { + var response = await fetch(url); + if (!response.ok) throw new Error("Network response was not ok"); + var text = await response.text(); + return parseSubscriptionToLines(text); + } catch (e) { + throw e; // Reraise to be caught by handleCopyNodeInfo for CORS check + } + } + + function parseSubscriptionToLines(text) { + var decoded = ""; + try { + decoded = utf8Base64Decode(text.trim()); + } catch (e) { + decoded = text; + } + return decoded.split(/\r?\n/).map(function(s) { return s.trim(); }).filter(Boolean); + } + + function findNodeLinkByName(links, name) { + if (!name) return null; + var target = name.trim(); + + console.log("Nebula: Searching for node '" + name + "' among " + links.length + " links (Strict Match)"); + + for (var i = 0; i < links.length; i++) { + var link = links[i].trim(); + var remark = ""; + + if (link.indexOf("#") !== -1) { + var parts = link.split("#"); + var rawRemark = parts[parts.length - 1]; + try { + remark = decodeURIComponent(rawRemark.replace(/\+/g, "%20")); + } catch (e) { + remark = rawRemark; + } + } else if (link.indexOf("vmess://") === 0) { + try { + var jsonStr = utf8Base64Decode(link.slice(8)); + var json = JSON.parse(jsonStr); + remark = json.ps || ""; + } catch (e) {} + } else if (link.indexOf("name:") !== -1) { + var yamlMatch = link.match(/name:\s*["']?([^"']+)["']?/); + if (yamlMatch) remark = yamlMatch[1]; + } + + // Strict exact match after trimming + if ((remark || "").trim() === target) { + return link; + } + } + + console.warn("Nebula: No exact match found for node '" + name + "'"); + return null; + } + + function maskClashYaml(yaml) { + if (!yaml) return ""; + return yaml + .replace(/server:\s*.+/g, "server: **********") + .replace(/password:\s*.+/g, "password: **********") + .replace(/uuid:\s*.+/g, "uuid: **********") + .replace(/public-key:\s*.+/g, "public-key: **********"); + } + + function linkToClash(link) { + if (!link) return ""; + + var remark = ""; + if (link.indexOf("#") !== -1) { + var parts = link.split("#"); + try { + remark = decodeURIComponent(parts[parts.length - 1].replace(/\+/g, "%20")); + } catch (e) { + remark = parts[parts.length - 1]; + } + } + + // Shadowsocks + if (link.indexOf("ss://") === 0) { + var body = link.split("#")[0].slice(5); + var parts = body.split("@"); + if (parts.length < 2) return link; + + var userinfo = parts[0]; + var serverinfo = parts[1]; + var decodedUser = ""; + try { + decodedUser = atob(userinfo.replace(/-/g, "+").replace(/_/g, "/")); + } catch (e) { + decodedUser = userinfo; + } + + var userParts = decodedUser.split(":"); + var serverParts = serverinfo.split(":"); + + return [ + "- name: \"" + (remark || "SS Node") + "\"", + " type: ss", + " server: " + serverParts[0], + " port: " + (serverParts[1] || 443), + " cipher: " + (userParts[0] || "aes-256-gcm"), + " password: " + (userParts[1] || ""), + " udp: true" + ].join("\n"); + } + + // VMess + if (link.indexOf("vmess://") === 0) { + try { + var json = JSON.parse(utf8Base64Decode(link.slice(8))); + var yaml = [ + "- name: \"" + (json.ps || remark || "VMess Node") + "\"", + " type: vmess", + " server: " + json.add, + " port: " + (json.port || 443), + " uuid: " + json.id, + " alterId: " + (json.aid || 0), + " cipher: auto", + " udp: true", + " tls: " + (json.tls === "tls" ? "true" : "false"), + " network: " + (json.net || "tcp"), + " servername: " + (json.sni || json.host || "") + ]; + if (json.net === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (json.path || "/")); + if (json.host) yaml.push(" headers:"); + if (json.host) yaml.push(" Host: " + json.host); + } + if (json.net === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (json.path || "")); + } + return yaml.join("\n"); + } catch (e) { + return link; + } + } + + // VLESS + if (link.indexOf("vless://") === 0) { + var body = link.split("#")[0].slice(8); + var main = body.split("?")[0]; + var query = body.split("?")[1] || ""; + var parts = main.split("@"); + var uuid = parts[0]; + var serverParts = (parts[1] || "").split(":"); + + var params = {}; + query.split("&").forEach(function (pair) { + var p = pair.split("="); + params[p[0]] = decodeURIComponent(p[1] || ""); + }); + + var yaml = [ + "- name: \"" + (remark || "VLESS Node") + "\"", + " type: vless", + " server: " + serverParts[0], + " port: " + (parseInt(serverParts[1]) || 443), + " uuid: " + uuid, + " udp: true", + " tls: " + (params.security !== "none" ? "true" : "false"), + " skip-cert-verify: true" + ]; + + if (params.sni) yaml.push(" servername: " + params.sni); + if (params.type) yaml.push(" network: " + params.type); + if (params.flow) yaml.push(" flow: " + params.flow); + if (params.fp) yaml.push(" client-fingerprint: " + params.fp); + if (params.security === "reality") { + yaml.push(" reality-opts:"); + if (params.pbk) yaml.push(" public-key: " + params.pbk); + if (params.sid) yaml.push(" short-id: " + params.sid); + } + if (params.type === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (params.path || "/")); + if (params.host) yaml.push(" headers:"); + if (params.host) yaml.push(" Host: " + params.host); + } + if (params.type === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (params.serviceName || "")); + } + + return yaml.join("\n"); + } + + // Trojan + if (link.indexOf("trojan://") === 0) { + var body = link.split("#")[0].slice(9); + var main = body.split("?")[0]; + var query = body.split("?")[1] || ""; + var parts = main.split("@"); + var password = parts[0]; + var serverParts = (parts[1] || "").split(":"); + + var params = {}; + query.split("&").forEach(function (pair) { + var p = pair.split("="); + params[p[0]] = decodeURIComponent(p[1] || ""); + }); + + var yaml = [ + "- name: \"" + (remark || "Trojan Node") + "\"", + " type: trojan", + " server: " + serverParts[0], + " port: " + (parseInt(serverParts[1]) || 443), + " password: " + password, + " udp: true", + " sni: " + (params.sni || params.peer || ""), + " skip-cert-verify: true" + ]; + + if (params.type === "ws") { + yaml.push(" ws-opts:"); + yaml.push(" path: " + (params.path || "/")); + if (params.host) yaml.push(" headers:"); + if (params.host) yaml.push(" Host: " + params.host); + } + if (params.type === "grpc") { + yaml.push(" grpc-opts:"); + yaml.push(" grpc-service-name: " + (params.serviceName || "")); + } + + return yaml.join("\n"); + } + + return link; + } + + function utf8Base64Decode(str) { + try { + var b64 = str.trim().replace(/-/g, "+").replace(/_/g, "/"); + while (b64.length % 4 !== 0) b64 += "="; + + var binary = atob(b64); + try { + if (typeof TextDecoder !== "undefined") { + var bytes = new Uint8Array(binary.length); + for (var i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new TextDecoder("utf-8").decode(bytes); + } + + return decodeURIComponent(binary.split("").map(function (c) { + return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); + }).join("")); + } catch (e) { + return binary; + } + } catch (e) { + throw new Error("Invalid base64 content: " + e.message); + } + } + + function onInput(event) { + var target = event.target; + if (target.matches("[data-action='search-servers']")) { + state.serverSearch = target.value; + state.nodePage = 1; + render(); + + var input = document.querySelector("[data-action='search-servers']"); + if (input) { + input.focus(); + input.setSelectionRange(target.value.length, target.value.length); + } + } + } + + async function handleEnableIpv6(actionEl) { + actionEl.disabled = true; + try { + var response = await fetchJson("/api/v1/user/user-add-ipv6-subscription/enable", { method: "POST" }); + var payload = unwrap(response) || {}; + if (payload.auth_data) { + saveIpv6Token(payload.auth_data); + state.ipv6AuthToken = getStoredIpv6Token(); + } + showMessage("IPv6 闇堬絹锟芥拰鑴o拷锟借崝锟界攪锟介妬锟界憻榘?..", "success"); + // Try to login to IPv6 account if possible, or just refresh dashboard + // Since we don't have the password here (state doesn't keep it), + // the user might need to sync password first or we assume it was synced during creation. + await loadDashboard(true); + render(); + } catch (error) { + showMessage(error.message || "鎾橈拷锟借嚞浠冮煇锟?, "error"); + } finally { + actionEl.disabled = false; + } + } + + async function handleSyncIpv6Password(actionEl) { + actionEl.disabled = true; + try { + await fetchJson("/api/v1/user/user-add-ipv6-subscription/sync-password", { method: "POST" }); + showMessage("鎾栵拷锟芥拰鑴o拷鐢囦簷锟?IPv6 闊愯锟?, "success"); + } catch (error) { + showMessage(error.message || "锟藉硶閮婃啳姊彇", "error"); + } finally { + actionEl.disabled = false; + } + } + + async function loadUplinkMetric() { + if (state.authToken) { + return; + } + var baseUrl = getMetricsBaseUrl(); + if (!baseUrl) { + return; + } + try { + var response = await fetch(baseUrl + "/api/metrics/overview", { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest" + } + }); + var payload = await response.json(); + var network = (payload && payload.network) || {}; + var tx = Number(network.tx || 0); + var total = tx; + if (total > 0) { + state.uplinkMbps = (total * 8) / 1000000; + var el = document.getElementById("nebula-hero-metric-stack"); + if (el) { + el.innerHTML = renderUplinkMetricStack(); + } else if (!state.authToken || !state.user) { + render(); + } + } + } catch (error) { + state.uplinkMbps = null; + } + } + + function onSubmit(event) { + var form = event.target; + if (!form.matches("[data-form]")) { + return; + } + + event.preventDefault(); + var formType = form.getAttribute("data-form"); + var data = Object.fromEntries(new FormData(form).entries()); + var submitButton = form.querySelector("[type='submit']"); + + if (submitButton) { + submitButton.disabled = true; + } + + if (formType === "create-ticket") { + fetchJson("/api/v1/user/ticket/save", { + method: "POST", + body: { + subject: data.subject || "", + level: data.level || "0", + message: data.message || "" + } + }).then(function () { + showMessage("鎾屼簷锟芥拰鑴o拷鎾憋拷", "success"); + state.selectedTicketId = null; + state.selectedTicket = null; + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "鎾屼簷锟斤拷馥暒閬f啳姊彇", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "real-name-verification") { + fetchJson("/api/v1/user/real-name-verification/submit", { + method: "POST", + body: { + real_name: data.real_name || "", + identity_no: data.identity_no || "" + } + }).then(function (response) { + var payload = unwrap(response) || {}; + var verification = payload.verification || payload; + state.realNameVerification = verification; + showMessage( + verification && verification.status === "approved" + ? "鎽梆Ъ拷闇堟柟锟芥拰鑴わ拷鏈烇拷锟斤拷" + : "闇堟柟锟介澖鈯ワ拷鎾岃劔锟介埈姝癸拷闇傜憺锟芥暫锟芥仯锟借┗锟斤拷", + "success" + ); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "闇堟柟锟斤拷穑偡婕辨啳姊彇", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "change-password") { + if (data.new_password !== data.confirm_password) { + showMessage("閵濇枟娲婚⒉鏋忥拷锟斤拷榘垫挅锟斤拷閵濇虎锟斤拷锟?, "error"); + if (submitButton) submitButton.disabled = false; + return; + } + fetchJson("/api/v1/user/changePassword", { + method: "POST", + body: { + old_password: data.old_password || "", + new_password: data.new_password || "" + } + }).then(function () { + showMessage("鎾栵拷锟芥拰鑴栬€拷灏嶏拷鎾辩畡鎮咃拷锟?IPv6 锟斤拷锟斤拷宄曢儕锟藉暎锟斤拷锟?, "success"); + form.reset(); + }).catch(function (error) { + showMessage(error.message || "鎾栵拷锟介澖鏍笺嚎鎲鎻?, "error"); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + if (formType === "forget-password") { + fetchJson("/api/v1/passport/auth/forget", { + method: "POST", + auth: false, + body: { + email: data.email, + email_code: data.email_code, + password: data.password + } + }).then(function () { + showMessage("鎾栵拷锟芥拰鑴わ拷铦垫锟介渹鐟氳潤锟藉喗榘垫挅锟斤拷锟介锟?, "success"); + state.mode = "login"; + render(); + }).catch(function (error) { + showMessage(error.message || "锟芥花钄啳姊彇", "error"); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + return; + } + + fetchJson(formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login", { + method: "POST", + auth: false, + body: data + }).then(function (response) { + var payload = unwrap(response); + if (!payload || !payload.auth_data) { + throw new Error("Authentication succeeded but token payload is missing"); + } + saveToken(payload.auth_data); + state.authToken = getStoredToken(); + + // Try IPv6 login/register if email/password available + if (data.email && data.password) { + var ipv6Email = data.email.replace("@", "-ipv6@"); + var ipv6Action = formType === "register" ? "/api/v1/passport/auth/register" : "/api/v1/passport/auth/login"; + + fetchJson(ipv6Action, { + method: "POST", + auth: false, + body: Object.assign({}, data, { email: ipv6Email }) + }).then(function (ipv6Response) { + var ipv6Payload = unwrap(ipv6Response); + if (ipv6Payload && ipv6Payload.auth_data) { + saveIpv6Token(ipv6Payload.auth_data); + state.ipv6AuthToken = getStoredIpv6Token(); + } + }).catch(function () { + // Ignore IPv6 errors as it might not be enabled yet or fail + }).finally(function() { + loadDashboard(true).then(render); + }); + } + + showMessage(formType === "register" ? "Account created" : "Signed in", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to continue", "error"); + render(); + }).finally(function () { + if (submitButton) { + submitButton.disabled = false; + } + }); + } + + function handleResetSecurity(actionEl, isIpv6) { + showNebulaModal({ + title: "鎽板導锟斤拷闉撅拷", + content: "铦栨锟介柆锟斤拷铦垫牚鎭ワ拷锟借獦锟斤拷锟藉殫缃革拷铦垫瑾橈拷锟斤拷鍤楋拷锟斤拷绁夛拷闇堬絹锟斤拷鍟o拷鎾狅拷浠冿拷锟斤拷闆筐3囷拷闁拷锟斤拷鍟楃巩锟界鍍庯拷婊ㄨ敪闇堬絹锟斤拷锟?, + confirmText: "铦栨牚鎭曪拷婊ㄨ敪", + onConfirm: function () { + actionEl.disabled = true; + fetchJson("/api/v1/user/resetSecurity", { method: "GET", ipv6: !!isIpv6 }).then(function (response) { + var subscribeUrl = unwrap(response); + var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; + if (sub && subscribeUrl) { + sub.subscribe_url = subscribeUrl; + } + showMessage((isIpv6 ? "IPv6 " : "") + "闇堬絹锟介殲鏂わ拷鎾岃劋锟借澋锟?, "success"); + render(); + }).catch(function (error) { + showMessage(error.message || "锟芥花钄啳姊彇", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + }); + } + + function handleRemoveSession(actionEl) { + var sessionId = actionEl.getAttribute("data-session-id"); + if (!sessionId) { + return; + } + + actionEl.disabled = true; + fetchJson("/api/v1/user/removeActiveSession", { + method: "POST", + body: { session_id: sessionId } + }).then(function () { + showMessage("Session revoked", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to revoke session", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleRemoveOtherSessions(actionEl) { + var sessions = ((state.sessionOverview && state.sessionOverview.sessions) || []).filter(function (session) { + return !session.is_current; + }); + + if (!sessions.length) { + showMessage("No other sessions to sign out.", "success"); + render(); + return; + } + + actionEl.disabled = true; + Promise.all(sessions.map(function (session) { + return fetchJson("/api/v1/user/removeActiveSession", { + method: "POST", + body: { session_id: session.id } + }); + })).then(function () { + showMessage("Signed out all other sessions.", "success"); + return loadDashboard(true); + }).then(render).catch(function (error) { + showMessage(error.message || "Unable to sign out other sessions", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleOpenTicketDetail(actionEl) { + var ticketId = actionEl.getAttribute("data-ticket-id"); + if (!ticketId) { + return; + } + actionEl.disabled = true; + fetchJson("/api/v1/user/ticket/fetch?id=" + encodeURIComponent(ticketId), { method: "GET" }).then(function (response) { + state.selectedTicketId = ticketId; + state.selectedTicket = unwrap(response); + render(); + }).catch(function (error) { + showMessage(error.message || "鎾屼簷锟介渹琛岋拷锟姐皹铦告啳姊彇", "error"); + render(); + }).finally(function () { + actionEl.disabled = false; + }); + } + + function handleOpenKnowledgeArticle(actionEl) { + var articleId = actionEl.getAttribute("data-article-id"); + if (!articleId) { + return; + } + + var article = findKnowledgeArticleById(articleId); + if (!article) { + showMessage("Knowledge article not found", "error"); + return; + } + + showKnowledgeArticleModal(article); + } + + var currentLayout = null; // "loading", "login", "dashboard" + + function render() { + if (state.loading) { + return; + } + + var isLoggedIn = !!(state.authToken && state.user); + var targetLayout = isLoggedIn ? "dashboard" : "login"; + + // If layout type changed (e.g. Login -> Dashboard), do a full shell render + if (currentLayout !== targetLayout) { + currentLayout = targetLayout; + app.innerHTML = isLoggedIn ? renderDashboard() : renderAuth(); + hideLoader(); + return; + } + + // If already in Dashboard, only update the content area and sidebar state + if (isLoggedIn) { + var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); + var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; + var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); + var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; + var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; + var overview = state.sessionOverview || {}; + + var contentArea = document.querySelector(".dashboard-main"); + if (contentArea) { + contentArea.innerHTML = renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); + } + + var sidebarContainer = document.querySelector(".dashboard-shell"); + if (sidebarContainer) { + var sidebar = sidebarContainer.querySelector(".dashboard-sidebar"); + if (sidebar) { + sidebar.innerHTML = renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, true); + } + } + } else { + // For auth page, simple re-render is fine + app.innerHTML = renderAuth(); + } + + hideLoader(); + } + + function renderAuth() { + return [ + '
', + renderTopbar(false), + '
', + '
', + '
' + escapeHtml(theme.description || "") + "
", + '
' + renderUplinkMetricStack() + '
', + (theme.config && theme.config.slogan) ? '

' + escapeHtml(theme.config.slogan) + "

" : "", + "
", + '
', + (theme.config && (theme.config.isRegisterEnabled || state.mode === "forget-password")) ? [ + '
', + tabButton("login", "锟介锟?, state.mode === "login"), + (theme.config && theme.config.isRegisterEnabled) ? tabButton("register", "鐦滃吀锟?, state.mode === "register") : "", + tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"), + "
" + ].join("") : [ + '
', + tabButton("login", "锟介锟?, state.mode === "login"), + tabButton("forget-password", "锟芥泬锟芥挅锟斤拷", state.mode === "forget-password"), + "
" + ].join(""), + renderAuthPanelHeading(), + renderAuthForm(), + "
", + "
", + renderRecordFooter(), + "
" + ].join(""); + } + + function renderDashboard() { + var usedTraffic = ((state.subscribe && state.subscribe.u) || 0) + ((state.subscribe && state.subscribe.d) || 0); + var totalTraffic = (state.subscribe && state.subscribe.transfer_enable) || 0; + var remainingTraffic = Math.max(totalTraffic - usedTraffic, 0); + var percent = totalTraffic > 0 ? Math.min(100, Math.round((usedTraffic / totalTraffic) * 100)) : 0; + var stats = Array.isArray(state.stats) ? state.stats : [0, 0, 0]; + var overview = state.sessionOverview || {}; + + return renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview); + } + + function renderTopbar(isLoggedIn) { + return [ + '
', + '
', + isLoggedIn ? '' : '', + renderBrandMark(), + '

' + escapeHtml(theme.title || "Nebula") + "

", + "
", + '
', + renderThemeToggle(), + isLoggedIn + ? '
", + "
" + ].join(""); + } + + function renderDashboardByRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { + return [ + '
', + renderTopbar(true), + '', + '
', + renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview), + '
', + renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview), + '
', + '
', + renderRecordFooter(), + '
' + ].join(""); + } + + function renderCurrentRoute(remainingTraffic, usedTraffic, totalTraffic, percent, stats, overview) { + if (state.currentRoute === "access-history") { + return [ + '
', + renderSessionSection(overview.sessions || []), + renderIpSection(overview.online_ips || []), + '
' + ].join(""); + } + if (state.currentRoute === "nodes") { + return [ + '
', + renderSubscribeSection("span-12 section-card--overview"), + renderServerSection(state.servers || []), + '
' + ].join(""); + } + if (state.currentRoute === "ipv6-nodes") { + return [ + '
', + renderIpv6SubscribeSection("span-12 section-card--overview"), + renderServerSection(state.ipv6Servers || [], true), + '
' + ].join(""); + } + if (state.currentRoute === "tickets") { + return [ + '
', + renderTicketComposer(), + renderTicketSection(state.tickets || []), + renderTicketDetailSection(state.selectedTicket), + '
' + ].join(""); + } + if (state.currentRoute === "real-name") { + return [ + '
', + renderRealNameVerificationPanel(), + '
' + ].join(""); + } + if (state.currentRoute === "security") { + return [ + '
', + renderSecuritySection(), + '
' + ].join(""); + } + + var html = [ + '
', + 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"), + '
' + ].join(""); + return html; + } + + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { + return [ + '
', + '
', + + function renderLiveUserSurface(remainingTraffic, usedTraffic, totalTraffic, percent, overview) { + return [ + '
', + '
', + 'Subscription', + "

" + escapeHtml(theme.title || "Nebula") + "

", + "

" + escapeHtml((state.subscribe && state.subscribe.plan && state.subscribe.plan.name) || "No active plan") + " 绻?" + escapeHtml((state.user && state.user.email) || "") + "

", + '
', + dashboardStat(formatTraffic(remainingTraffic), "Remaining traffic"), + dashboardStat(String(overview.online_ip_count || 0), "Current online IPs"), + dashboardStat(String(overview.active_session_count || 0), "Stored sessions"), + '
', + '
', + '', + '
', + renderSubscribeSection(), + '
' + ].join(""); + } + + function renderAccessSnapshot(overview) { + return [ + '
', + renderAccessSnapshotCard(overview), + renderOpsSection(Array.isArray(state.stats) ? state.stats : [0, 0, 0], overview), + '
' + ].join(""); + } + + function renderAccessSnapshotCard(overview, extraClass) { + extraClass = extraClass || "span-7"; + var ipv6Overview = state.ipv6SessionOverview; + var limitDisplay = formatLimit(overview.device_limit) + (ipv6Overview ? " / " + formatLimit(ipv6Overview.device_limit) : ""); + return [ + '
', + '
璁块棶璁板綍

璁惧姒傝

', + '
', + 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)), + '
', + '
' + ].join(""); + } + + function renderIpv6TrafficOverviewCard(extraClass) { + if (!state.ipv6AuthToken || !state.ipv6Subscribe) { + return [ + '
', + '
IPv6 娴侀噺

IPv6 娴侀噺姒傝

', + '
IPv6 璐﹀彿鏈惎鐢ㄦ垨鍔犺浇澶辫触銆?/div>', + '
' + ].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 [ + '
', + '
娴侀噺

' + prefix + '鐢ㄦ埛娴侀噺姒傝

', + '
', + kpiBox("鍓╀綑", formatTraffic(remainingTraffic)), + kpiBox("宸茬敤", formatTraffic(usedTraffic)), + kpiBox("鎬婚噺", formatTraffic(totalTraffic)), + kpiBox("浣跨敤鐜?, String(percent) + "%"), + '
', + '
', + '
' + ].join(""); + } + + function renderSidebar(remainingTraffic, usedTraffic, totalTraffic, percent, overview, innerOnly) { + var content = [ + '' + ].join(""); + content = content.replace(/
', + sidebarLink("real-name", "瀹炲悕璁よ瘉", "鎻愪氦涓庢煡鐪嬪疄鍚嶅鏍哥姸鎬?) + '
' + ); + + if (innerOnly) return content; + + return [ + '' + ].join(""); + } + + function renderAuthForm() { + var isRegister = state.mode === "register"; + var isForget = state.mode === "forget-password"; + + if (isForget) { + return [ + '
', + '
', + '
', + '
', + '鍙戦€侀獙璇佺爜', + '
', + '
', + '
', + '', + "
", + "
" + ].join(""); + } + + return [ + '
', + '
', + '
', + '
', + '", + "
", + "
" + ].join(""); + } + + function renderIpv6SubscribeSection(extraClass) { + return renderSubscribeSection(extraClass, true); + } + + function renderSubscribeSection(extraClass, isIpv6) { + extraClass = extraClass || "span-7"; + var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; + var user = isIpv6 ? state.ipv6User : state.user; + var ipv6Eligibility = state.ipv6Eligibility || {}; + var prefix = isIpv6 ? "IPv6 " : ""; + var copyAction = isIpv6 ? "copy-ipv6-subscribe" : "copy-subscribe"; + + if (isIpv6 && !state.ipv6AuthToken) { + } else if (isIpv6 && !sub) { + return ""; + } + + var html = [ + '
', + '
' + prefix + '璁㈤槄

杩炴帴宸ュ叿

', + '', + isIpv6 ? '' : '', + "
", + '
', + !isIpv6 ? kpiBox("濂楅", escapeHtml((sub && sub.plan && sub.plan.name) || "鏆傛棤濂楅")) : "", + kpiBox("闄愰€?, formatSpeed(sub && sub.speed_limit)), + kpiBox("閲嶇疆鏃?, String((sub && sub.reset_day) || "-")), + !isIpv6 ? kpiBox("閭", '' + escapeHtml((user && user.email) || "-") + "") : "", + "
", + (isIpv6 && !state.ipv6AuthToken) ? '' : '', + "
" + ].join(""); + if (isIpv6 && !state.ipv6AuthToken) { + if (!ipv6Eligibility.allowed) { + html = html.replace( + /
', + renderSessions(sessions), + "" + ].join(""); + } + + function renderServerSection(servers, isIpv6) { + return [ + '
', + '
', + '
' + (isIpv6 ? "IPv6 " : "") + '锟斤拷锟?/span>

' + (isIpv6 ? "IPv6 " : "") + '锟芥粴穰洑锟斤拷锟? + '

', + '
', + '', + '
', + '
', + renderServers(servers), + "
" + ].join(""); + } + + function renderKnowledgeSection(articles) { + if (!articles.length) { + return [ + '
', + '
锟戒亥锟芥懆锟?/span>

锟戒亥锟斤拷锟芥崋

', + '
锟斤拷皈拕鐦濃姤锟斤拷锟芥崋锟斤拷
', + "
" + ].join(""); + } + + // Group articles by category if possibly present, or just list them + return [ + '
', + '
锟戒亥锟芥懆锟?/span>

锟藉埜锟斤拷锟斤拷

', + '
' + articles.map(function (article) { + return [ + '
', + '
', + "" + escapeHtml(article.title || "锟借姼穰盯锟介娇锟借潯锟?) + "", + '' + escapeHtml(truncate(article.body || article.content || "", 160)) + "", + "
", + article.category ? '' + escapeHtml(article.category) + '' : formatDate(article.updated_at || article.created_at), + "
" + ].join(""); + }).join("") + "
", + "
" + ].join(""); + } + + function renderTicketSection(tickets) { + return [ + '
', + '
鎾屼簷锟?/span>

鎾屼簷锟斤拷稹憭鈥?/h3>

', + renderTickets(tickets), + "
" + ].join(""); + } + + function renderTicketComposer() { + return [ + '
', + '
锟藉暎閬?/span>

锟金﹂仯鎾屼簷锟?/h3>

', + '
', + '
', + '
', + '
', + '
', + "
" + ].join(""); + } + + function renderTicketDetailSection(ticket) { + if (!ticket) { + return ""; + } + return [ + '
', + '
闇傝锟?/span>

' + escapeHtml(ticket.subject || ("鎾屼簷锟?#" + ticket.id)) + '

', + '
', + kpiBox("锟藉梿锟斤拷", renderTicketStatus(ticket.status)), + kpiBox("铦戝帺婕?, getTicketLevelLabel(ticket.level)), + kpiBox("锟金﹂仯锟藉湌稹⒖", formatDate(ticket.created_at)), + kpiBox("锟芥箶榘碉拷鍦掟、?, formatDate(ticket.updated_at)), + '
', + renderTicketMessages(ticket.message || []), + "
" + ].join(""); + } + + + function renderIpList(ips) { + var list = ips.slice(); + if (!list.length) { + return '
鏁舵瀼锟介鋩圭摲锟姐棁锟界槡瑙侊拷锟藉嚱鐟?IP 闇堝暎锟斤拷锟?/div>'; + } + return '
' + list.map(function (ip) { + return '
锟藉嚱鐟湀鏇夛拷 IP' + escapeHtml(ip) + '
'; + }).join("") + "
"; + } + + function renderSessions(sessions) { + if (!sessions.length) { + return '
鏁舵瀼锟界槤鈯ワ拷闅℃湠锟介湀鍟o拷锟斤拷
'; + } + return '
' + sessions.map(function (session) { + var tag = session.is_current ? '鏁舵瀼锟?/span>' : '鎾岃剸锟芥懏锟?/span>'; + var revoke = session.is_current ? "" : '
"; + } + + function renderServers(servers) { + if (!servers.length) { + return '
鏁舵瀼锟界槤鈯ワ拷锟借垚閸傦拷锟斤拷锟斤拷
'; + } + 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>'; + } + + 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 '
' + pagedServers.map(function (server) { + var tags = normalizeServerTags(server.tags); + return [ + '
', + '
', + '
', + "" + escapeHtml(server.name || "锟借姼穰盯锟芥哗锟斤拷锟?) + "", + '' + escapeHtml(server.type || "node") + "", + "
", + '', + "
", + '
', + nodeSpec("锟芥花锟?, escapeHtml(String(server.rate || 1)) + "x"), + nodeSpec("锟藉梿锟斤拷", '' + (server.is_online ? "锟藉嚱鐟? : "铦抽鐟?) + ""), + "
", + tags.length ? '
' + tags.map(function (tag) { + return '' + escapeHtml(String(tag)) + ""; + }).join("") + "
" : "", + "
" + ].join(""); + }).join("") + '
' + renderNodePagination(totalPages) + "
"; + } + + function renderNotices(articles) { + if (!articles.length) { + return '
锟斤拷皈拕鐦濃姤锟斤拷锟芥崋锟斤拷
'; + } + return '
' + articles.map(function (article) { + return [ + '
', + '
', + "" + escapeHtml(article.title || "锟界爫锟?) + "", + '' + escapeHtml(truncate(article.body || article.content || "", 160)) + "", + "
", + '' + formatDate(article.updated_at || article.created_at) + "", + "
" + ].join(""); + }).join("") + "
"; + } + + function renderTickets(tickets) { + if (!tickets.length) { + return '
鏁舵瀼锟界槤鈯ワ拷鎾屼簷锟介湀鍟o拷锟斤拷
'; + } + return '
' + tickets.map(function (ticket) { + return [ + '
', + '
', + "" + escapeHtml(ticket.subject || ("鎾屼簷锟?#" + ticket.id)) + "", + '铦戝帺婕?' + escapeHtml(getTicketLevelLabel(ticket.level)) + ' 绻?锟芥箶榘碉拷鍦掟、?' + formatDate(ticket.updated_at || ticket.created_at) + "", + "
", + '
' + renderTicketStatus(ticket.status) + '
', + "
" + ].join(""); + }).join("") + "
"; + } + + function renderTicketMessages(messages) { + if (!messages.length) { + return '
鏁舵瀼锟芥拰浜欙拷椁堜€圭摲锟姐棁鍑掓啳稹亸锟斤拷鑳拷锟?/div>'; + } + return '
' + messages.map(function (message) { + return [ + '
', + '
', + '' + (message.is_me ? "锟斤拷" : "鎽帮几锟?) + "", + '' + formatDate(message.created_at) + "", + '' + escapeHtml(message.message || "") + "", + "
", + "
" + ].join(""); + }).join("") + "
"; + } + + function renderTicketStatus(status) { + var isOpen = Number(status) === 0; + return '' + (isOpen ? "鎲拷锟介姖锟? : "鎾岃劊锟斤拷锟?) + ""; + } + + function getTicketLevelLabel(level) { + if (String(level) === "2") { + return "鎿冿拷"; + } + if (String(level) === "1") { + return "閵濓拷"; + } + return "闆匡拷"; + } + + function metricBox(value, label) { + return '
' + escapeHtml(value) + '' + escapeHtml(label) + "
"; + } + + function nodeSpec(label, value) { + return '
' + escapeHtml(label) + '' + value + "
"; + } + + function renderNodePagination(totalPages) { + if (totalPages <= 1) { + return ""; + } + return [ + '
', + '', + '铦氾拷 ' + String(state.nodePage) + " / " + String(totalPages) + " 鎲匡拷", + '
" + ].join(""); + } + + function getNodePageSize() { + var width = window.innerWidth || document.documentElement.clientWidth || 1280; + if (width <= 860) { + return 2; + } + if (width <= 1180) { + return 4; + } + return 6; + } + + function compareServers(left, right) { + var leftOnline = left && left.is_online ? 1 : 0; + var rightOnline = right && right.is_online ? 1 : 0; + if (leftOnline !== rightOnline) { + return rightOnline - leftOnline; + } + + var leftRate = toSortableNumber(left && left.rate, Number.POSITIVE_INFINITY); + var rightRate = toSortableNumber(right && right.rate, Number.POSITIVE_INFINITY); + if (leftRate !== rightRate) { + return leftRate - rightRate; + } + + var leftCheckedAt = toTimestamp(left && left.last_check_at) || 0; + var rightCheckedAt = toTimestamp(right && right.last_check_at) || 0; + if (leftCheckedAt !== rightCheckedAt) { + return rightCheckedAt - leftCheckedAt; + } + + return String((left && left.name) || "").localeCompare(String((right && right.name) || "")); + } + + function normalizeServerTags(tags) { + if (!tags) { + return []; + } + if (Array.isArray(tags)) { + return tags.filter(Boolean); + } + if (typeof tags === "string") { + var normalized = tags.trim(); + if (!normalized || normalized === "[]" || normalized === "null" || normalized === '""') { + return []; + } + if ((normalized.charAt(0) === "[" && normalized.charAt(normalized.length - 1) === "]") || + (normalized.charAt(0) === "{" && normalized.charAt(normalized.length - 1) === "}")) { + try { + var parsed = JSON.parse(normalized); + if (Array.isArray(parsed)) { + return parsed.map(function (tag) { + return String(tag).trim(); + }).filter(Boolean); + } + } catch (error) { + normalized = normalized.replace(/^\[/, "").replace(/\]$/, ""); + } + } + return normalized.split(/[|,\/]/).map(function (tag) { + return tag.trim().replace(/^["'\[]+|["'\]]+$/g, ""); + }).filter(Boolean); + } + return []; + } + + function toSortableNumber(value, fallback) { + var numeric = Number(value); + return Number.isFinite(numeric) ? numeric : fallback; + } + + function buildSessionOverview(list, extra, isIpv6) { + var ipMap = {}; + var deviceMap = {}; + var lastOnlineAt = null; + + var normalizedSessions = list.map(function (session) { + var lastUsedAt = toTimestamp(session.last_used_at); + var createdAt = toTimestamp(session.created_at); + var expiresAt = toTimestamp(session.expires_at); + var sessionName = session.name || session.device_name || session.user_agent || ("Session #" + (session.id || "")); + var sessionIp = firstNonEmpty([ + session.ip, + session.ip_address, + session.last_used_ip, + session.login_ip, + session.current_ip + ]); + var deviceKey = firstNonEmpty([ + session.device_name, + session.user_agent, + session.client_type, + session.os, + sessionName + ]) || ("device-" + Math.random().toString(36).slice(2)); + + if (sessionIp) { + ipMap[sessionIp] = true; + } + if (deviceKey) { + deviceMap[deviceKey] = true; + } + if (lastUsedAt && (!lastOnlineAt || lastUsedAt > lastOnlineAt)) { + lastOnlineAt = lastUsedAt; + } + + return { + id: session.id, + name: sessionName, + abilities: session.abilities, + ip: sessionIp, + user_agent: session.user_agent || "", + last_used_at: lastUsedAt, + created_at: createdAt, + expires_at: expiresAt, + is_current: Boolean(session.is_current || session.current || session.is_login || session.this_device) + }; + }); + + var sub = isIpv6 ? state.ipv6Subscribe : state.subscribe; + + return { + online_ip_count: Object.keys(ipMap).length, + online_ips: Object.keys(ipMap), + online_device_count: Object.keys(deviceMap).length, + device_limit: extra.device_limit || (sub ? (sub.device_limit || (sub.plan && sub.plan.device_limit) || null) : null), + last_online_at: toTimestamp(extra.last_online_at) || lastOnlineAt, + active_session_count: normalizedSessions.length, + sessions: normalizedSessions + }; + } + + function dashboardStat(value, label) { + return '
' + escapeHtml(value) + '' + escapeHtml(label) + "
"; + } + + function kpiBox(label, value) { + return '
' + label + '' + value + "
"; + } + + function sidebarLink(route, title, copy) { + return '"; + } + + function renderBrandMark() { + var logoUrl = getThemeLogo(); + if (logoUrl) { + return '' + escapeHtml(theme.title || '; + } + return ''; + } + + function renderThemeToggle() { + var isLight = state.themeMode === "light"; + return [ + '' + ].join(""); + } + + function renderRecordFooter() { + var records = []; + if (theme.config && theme.config.icpNo) { + records.push('' + escapeHtml(theme.config.icpNo) + ''); + } + if (theme.config && theme.config.psbNo) { + records.push('' + escapeHtml(theme.config.psbNo) + ''); + } + if (!records.length) { + return ""; + } + return '
' + records.join('|') + '
'; + } + + function renderAuthPanelHeading() { + return [ + '
', + '

Welcome To

', + '

' + escapeHtml(getAuthPanelTitle()) + "

", + '
' + ].join(""); + } + + function getAuthPanelTitle() { + if (state.mode === "register") { + return (theme.config && theme.config.registerTitle) || "Create your access."; + } + return (theme.config && theme.config.welcomeTarget) || (theme && theme.title) || "Your Space"; + } + + function onRouteChange() { + state.currentRoute = getCurrentRoute(); + if (state.authToken && state.user) { + render(); + } + } + + function setCurrentRoute(route) { + var nextRoute = isValidRoute(route) ? route : "overview"; + state.currentRoute = nextRoute; + window.location.hash = "#/" + nextRoute; + render(); + } + + function setNodePage(page) { + state.nodePage = page > 0 ? page : 1; + render(); + } + + function toggleThemeMode() { + state.themePreference = state.themeMode === "light" ? "dark" : "light"; + persistThemePreference(state.themePreference); + applyThemeMode(); + render(); + } + + function getCurrentRoute() { + var hash = String(window.location.hash || ""); + var route = hash.indexOf("#/") === 0 ? hash.slice(2) : ""; + return isValidRoute(route) ? route : "overview"; + } + + function getStoredThemePreference() { + var stored = ""; + try { + stored = window.localStorage.getItem("nebula_theme_preference") || ""; + } catch (error) { + stored = ""; + } + if (stored === "light" || stored === "dark" || stored === "system") { + return stored; + } + return getDefaultThemePreference(); + } + + function persistThemePreference(mode) { + try { + window.localStorage.setItem("nebula_theme_preference", mode); + } catch (error) { + return; + } + } + + function applyThemeMode() { + state.themeMode = resolveThemeMode(state.themePreference); + document.documentElement.dataset.themeMode = state.themeMode; + document.documentElement.dataset.themePreference = state.themePreference; + updateThemeColorMeta(); + } + + function getThemeToggleLabel() { + return state.themeMode === "light" ? "锟斤拷鎻拷鍠诧拷鎲█h妺鎾橈拷" : "锟斤拷鎻拷鍠熻捑鎲媷鑺嬫挊锟?; + } + + function getRealNameVerificationState() { + return state.realNameVerification || { + enabled: false, + status: "unavailable", + status_label: "Unavailable", + can_submit: false, + real_name: "", + identity_no_masked: "", + reject_reason: "", + notice: "", + submitted_at: null, + reviewed_at: null + }; + } + + function getRealNameStatusTone(status) { + if (status === "approved") { + return "online"; + } + if (status === "rejected") { + return "offline"; + } + if (status === "pending") { + return "pending"; + } + return "neutral"; + } + + function renderRealNameStatusChip(status, label) { + return '' + escapeHtml(label || "锟借姲浒伙拷鍡嗭拷锟?) + ''; + } + + function renderRealNameVerificationOverviewCard(extraClass) { + var verification = getRealNameVerificationState(); + if (!verification.enabled) { + return ""; + } + + var submitted = verification.submitted_at ? formatDate(verification.submitted_at) : "-"; + var reviewed = verification.reviewed_at ? formatDate(verification.reviewed_at) : "-"; + + var html = [ + '
', + '
鎽梆Ъ拷闇堟柟锟?/span>

闇堟柟锟介澖鈯ワ拷

', + '
', + kpiBox("闇堟柟锟斤拷鍡嗭拷锟?, escapeHtml(verification.status_label || "锟借娋鎭曢渹锟?)), + kpiBox("锟界瑪锟芥啰鏋忥拷", escapeHtml(verification.real_name || "-")), + kpiBox("闇傦拷杈o拷鐟烇拷", escapeHtml(verification.identity_no_masked || "-")), + '
', + verification.reviewed_at ? '' : "", + verification.reject_reason ? '
鎽扳姤鐡叉啳锟介嚕: ' + escapeHtml(verification.reject_reason) + '
' : "", + '
' + ].join(""); + } + + function renderRealNameVerificationPanel() { + var verification = getRealNameVerificationState(); + if (!verification.enabled) { + return ""; + } + + var detailBlocks = [ + '
', + '
鏁舵瀼锟斤拷鍡嗭拷锟?/span>' + renderRealNameStatusChip(verification.status, verification.status_label) + '
', + '
锟界瑪锟芥啰鏋忥拷' + escapeHtml(verification.real_name || "-") + '
', + '
闋ㄦ€濋仮闇傦拷鍣?/span>' + escapeHtml(verification.identity_no_masked || "-") + '
', + '
锟金锋急锟藉湌稹⒖' + escapeHtml(verification.submitted_at ? formatDate(verification.submitted_at) : "-") + '
', + '
' + ]; + + if (verification.reviewed_at) { + detailBlocks.push(''); + } + + if (verification.reject_reason) { + detailBlocks.push('
鎾藉柍锟斤拷绗旓拷: ' + escapeHtml(verification.reject_reason) + '
'); + } else if (verification.notice) { + detailBlocks.push('
锟介灍鍏? ' + escapeHtml(verification.notice) + '
'); + } + + if (!verification.can_submit) { + return [ + '
', + '
鎽梆Ъ拷闇堟柟锟?/span>

闇堟柟锟介澖鈯ワ拷

', + detailBlocks.join(""), + '
' + ].join(""); + } + + return [ + '
', + '
鎽梆Ъ拷闇堟柟锟?/span>

锟金锋急闇堟柟锟?/h3>

', + detailBlocks.join(""), + '
', + '
', + '
', + '
锟藉嚱锟介牗鎬濋仮闇傦拷鍣★拷锟斤拷闅炰簷锟芥挅锟借€︽挊瑾╋拷锟藉墎锟芥挓鍡呴妬锟芥壒锟斤拷鍞憠锟芥湜锟斤拷鏇勫収锟斤拷
', + '
', + '
', + '
' + ].join(""); + } + + function renderSecuritySection() { + return [ + '
', + '
闊愯锟介湀鏇勮敪

闈芥牸愫挎挅锟斤拷

', + '
', + '
', + '
', + '
', + '', + '
', + '
', + "
", + '
', + '
鎽板導锟斤拷婊拷

闇堬絹锟介殲鏂わ拷铦炩垹锟?/h3>

', + '
', + '
IPv4 闇堬絹锟?/span>
', + '
IPv6 闇堬絹锟?/span>
', + '
', + '
' + ].join(""); + } + + function getThemeLogo() { + var config = theme.config || {}; + if (state.themeMode === "light") { + return config.lightLogoUrl || config.darkLogoUrl || theme.logo || ""; + } + return config.darkLogoUrl || theme.logo || config.lightLogoUrl || ""; + } + + function updateThemeColorMeta() { + var meta = document.querySelector('meta[name="theme-color"]'); + if (meta) { + meta.setAttribute("content", state.themeMode === "light" ? "#eef4fb" : "#08101c"); + } + } + + function getDefaultThemePreference() { + var configMode = theme && theme.config ? theme.config.defaultThemeMode : ""; + if (configMode === "light" || configMode === "dark" || configMode === "system") { + return configMode; + } + return "system"; + } + + function resolveThemeMode(preference) { + if (preference === "light" || preference === "dark") { + return preference; + } + return isSystemDarkMode() ? "dark" : "light"; + } + + function bindSystemThemeListener() { + if (!themeMediaQuery) { + return; + } + var onThemeChange = function () { + if (state.themePreference === "system") { + applyThemeMode(); + render(); + } + }; + if (typeof themeMediaQuery.addEventListener === "function") { + themeMediaQuery.addEventListener("change", onThemeChange); + return; + } + if (typeof themeMediaQuery.addListener === "function") { + themeMediaQuery.addListener(onThemeChange); + } + } + + function getThemeMediaQuery() { + if (!window.matchMedia) { + return null; + } + return window.matchMedia("(prefers-color-scheme: dark)"); + } + + function isSystemDarkMode() { + return Boolean(themeMediaQuery && themeMediaQuery.matches); + } + + function getMetricsBaseUrl() { + var baseUrl = theme && theme.config ? theme.config.metricsBaseUrl : ""; + return String(baseUrl || "").replace(/\/+$/, ""); + } + + function getUplinkHeadline() { + if (!Number.isFinite(state.uplinkMbps)) { + return "Delivering -- Mbps"; + } + return "Delivering " + formatUplinkMbps(state.uplinkMbps) + " Mbps"; + } + + function renderUplinkMetricStack() { + return [ + '
Delivering
', + '
' + escapeHtml(getUplinkValueText()) + '
', + '
Uplink Bandwidth
' + ].join(""); + } + + function getUplinkValueText() { + if (!Number.isFinite(state.uplinkMbps)) { + return "-- Mbps"; + } + return formatUplinkMbps(state.uplinkMbps) + " Mbps"; + } + + function formatUplinkMbps(value) { + if (value >= 100) { + return String(Math.round(value)); + } + if (value >= 10) { + return value.toFixed(1); + } + return value.toFixed(2); + } + + function isValidRoute(route) { + return [ + "overview", + "access-history", + "nodes", + "ipv6-nodes", + "tickets", + "real-name", + "security" + ].indexOf(route) !== -1; + } + + function getRouteTitle(route) { + if (route === "access-history") { + return "闇堣几鋽湀鍟o拷"; + } + if (route === "nodes") { + return "锟斤拷锟?; + } + if (route === "ipv6-nodes") { + return "IPv6 锟斤拷锟?; + } + if (route === "notices") { + return "锟戒亥锟芥懆锟?; + } + if (route === "tickets") { + return "鎾屼簷锟?; + } + if (route === "real-name") { + return "\u5b9e\u540d\u8ba4\u8bc1"; + } + if (route === "security") { + return "闊愯鍣℃懓鍖э拷"; + } + return "锟介锟?; + } + + function tabButton(mode, label, active) { + return '"; + } + + function getStoredToken() { + var candidates = []; + collectStorageValues(window.localStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates); + collectStorageValues(window.sessionStorage, ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"], candidates); + for (var i = 0; i < candidates.length; i += 1) { + var token = extractToken(candidates[i]); + if (token) { + return token; + } + } + return ""; + } + + function saveToken(token) { + var normalized = normalizeAuthHeader(token); + window.localStorage.setItem("access_token", normalized); + window.localStorage.setItem("auth_data", normalized); + window.localStorage.setItem("__nebula_auth_data__", normalized); + window.sessionStorage.setItem("access_token", normalized); + window.sessionStorage.setItem("auth_data", normalized); + window.sessionStorage.setItem("__nebula_auth_data__", normalized); + } + + function clearToken() { + window.localStorage.removeItem("access_token"); + window.localStorage.removeItem("auth_data"); + window.localStorage.removeItem("__nebula_auth_data__"); + window.sessionStorage.removeItem("access_token"); + window.sessionStorage.removeItem("auth_data"); + window.sessionStorage.removeItem("__nebula_auth_data__"); + state.authToken = ""; + } + + function getStoredIpv6Token() { + var stored = ""; + try { + stored = window.localStorage.getItem("__nebula_ipv6_auth_data__") || ""; + } catch (e) { + stored = ""; + } + return stored; + } + + function saveIpv6Token(token) { + window.localStorage.setItem("__nebula_ipv6_auth_data__", normalizeAuthHeader(token)); + } + + function clearIpv6Token() { + window.localStorage.removeItem("__nebula_ipv6_auth_data__"); + state.ipv6AuthToken = ""; + } + + function resetIpv6DashboardState() { + clearIpv6Token(); + state.ipv6User = null; + state.ipv6Subscribe = null; + state.ipv6Eligibility = null; + state.ipv6Servers = []; + state.ipv6SessionOverview = null; + state.ipv6CachedSubNodes = null; + window.sessionStorage.removeItem("__nebula_ipv6_sub_content__"); + } + + function handleIpv6AuthFailure(error) { + if (!error || (error.status !== 401 && error.status !== 403)) { + return false; + } + resetIpv6DashboardState(); + return true; + } + + function resetDashboard() { + state.user = null; + state.subscribe = null; + state.stats = null; + state.knowledge = []; + state.tickets = []; + state.selectedTicketId = null; + state.selectedTicket = null; + state.servers = []; + state.ipv6AuthToken = ""; + state.ipv6User = null; + state.ipv6Subscribe = null; + state.ipv6Eligibility = null; + state.ipv6Servers = []; + state.sessionOverview = null; + state.ipv6SessionOverview = null; + state.realNameVerification = null; + state.appConfig = null; + state.cachedSubNodes = null; + state.ipv6CachedSubNodes = null; + state.loading = false; + } + + function applyCustomBackground() { + if (!theme.config || !theme.config.backgroundUrl) { + document.documentElement.style.removeProperty("--nebula-bg-image"); + return; + } + document.documentElement.style.setProperty("--nebula-bg-image", 'url("' + String(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")'); + } + + function getVerifyToken() { + var url = new URL(window.location.href); + var directVerify = url.searchParams.get("verify"); + if (directVerify) { + return directVerify; + } + if (window.location.hash.indexOf("?") !== -1) { + return new URLSearchParams(window.location.hash.split("?")[1] || "").get("verify"); + } + return ""; + } + + function clearVerifyToken() { + var url = new URL(window.location.href); + url.searchParams.delete("verify"); + if (window.location.hash.indexOf("?") !== -1) { + window.history.replaceState({}, "", url.pathname + url.search + window.location.hash.split("?")[0]); + return; + } + window.history.replaceState({}, "", url.pathname + url.search); + } + + function showMessage(message, type) { + var container = document.getElementById("nebula-toasts"); + if (!container) return; + + if (!message) { + container.innerHTML = ""; + return; + } + + container.innerHTML = '
' + escapeHtml(message) + "
"; + + // Auto clear after 4s + if (state.messageTimeout) clearTimeout(state.messageTimeout); + state.messageTimeout = window.setTimeout(function() { + container.innerHTML = ""; + }, 4000); + } + + function hideLoader() { + if (!loader) { + return; + } + loader.classList.add("is-hidden"); + window.setTimeout(function () { + if (loader && loader.parentNode) { + loader.parentNode.removeChild(loader); + loader = null; + } + }, 350); + } + + function collectStorageValues(storage, keys, target) { + if (!storage) { + return; + } + for (var i = 0; i < keys.length; i += 1) { + pushCandidate(storage.getItem(keys[i]), target); + } + } + + function pushCandidate(value, target) { + if (value === null || typeof value === "undefined" || value === "") { + return; + } + target.push(value); + } + + function extractToken(value, depth) { + depth = depth || 0; + if (depth > 4 || value === null || typeof value === "undefined") { + return ""; + } + if (typeof value === "string") { + var trimmed = value.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.indexOf("Bearer ") === 0) { + return trimmed; + } + if (/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(\.[A-Za-z0-9\-_.+/=]+)?$/.test(trimmed)) { + return "Bearer " + trimmed; + } + if (/^[A-Za-z0-9\-_.+/=]{24,}$/.test(trimmed) && trimmed.indexOf("{") === -1) { + return "Bearer " + trimmed; + } + if ((trimmed.charAt(0) === "{" && trimmed.charAt(trimmed.length - 1) === "}") || + (trimmed.charAt(0) === "[" && trimmed.charAt(trimmed.length - 1) === "]")) { + try { + return extractToken(JSON.parse(trimmed), depth + 1); + } catch (error) { + return ""; + } + } + return ""; + } + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i += 1) { + var arrayToken = extractToken(value[i], depth + 1); + if (arrayToken) { + return arrayToken; + } + } + return ""; + } + if (typeof value === "object") { + var directKeys = ["access_token", "auth_data", "token", "Authorization", "authorization"]; + for (var j = 0; j < directKeys.length; j += 1) { + if (Object.prototype.hasOwnProperty.call(value, directKeys[j])) { + var directToken = extractToken(value[directKeys[j]], depth + 1); + if (directToken) { + return directToken; + } + } + } + } + return ""; + } + + function normalizeAuthHeader(token) { + var trimmed = String(token || "").trim(); + if (!trimmed) { + return ""; + } + if (trimmed.indexOf("Bearer ") === 0) { + return trimmed; + } + return "Bearer " + trimmed; + } + + function copyText(value, successMessage) { + if (!value) { + return; + } + navigator.clipboard.writeText(value).then(function () { + showMessage(successMessage || "锟斤拷鎹嗘拰鑴o拷锟藉梾锟斤拷鑺炬枑锟斤拷", "success"); + render(); + }).catch(function () { + showMessage("鎲粴锟芥啳姊彇鍤楄绐堟挔鑲斤拷锟借稷啞鎲粴锟?, "error"); + render(); + }); + } + + function formatTraffic(value) { + var units = ["B", "KB", "MB", "GB", "TB"]; + var size = Number(value || 0); + var index = 0; + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + return size.toFixed(size >= 10 || index === 0 ? 0 : 1) + " " + units[index]; + } + + function formatMoney(value, symbol) { + return (symbol || "$") + (Number(value || 0) / 100).toFixed(2); + } + + function formatSpeed(value) { + if (!value) { + return "閵濇激锟?; + } + return value + " Mbps"; + } + + function formatLimit(value) { + if (value === null || typeof value === "undefined" || value === "") { + return "閵濇激锟?; + } + return String(value); + } + + function formatDate(value) { + var timestamp = toTimestamp(value); + if (!timestamp) { + return "-"; + } + return new Date(timestamp * 1000).toLocaleString(); + } + + function toTimestamp(value) { + if (!value) { + return null; + } + if (typeof value === "number") { + return value; + } + var parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000); + } + + function truncate(text, maxLength) { + var str = String(text || ""); + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength - 1) + "..."; + } + + function firstNonEmpty(values) { + for (var i = 0; i < values.length; i += 1) { + if (values[i]) { + return values[i]; + } + } + return ""; + } + + function escapeHtml(value) { + return String(value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + function renderKnowledgeSection(articles) { + if (!articles.length) { + return [ + '
', + '
Knowledge

Knowledge Base

', + '
No knowledge articles yet.
', + "
" + ].join(""); + } + + return [ + '
', + '
Knowledge

Knowledge Base

', + '
' + articles.map(function (article) { + return [ + '" + ].join(""); + }).join("") + "
", + "
" + ].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 '

No article body provided.

'; + } + + return '
' + escapeHtml(content).replace(/\r?\n/g, "
") + '
'; + } + + 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 = [ + '
', + 'Knowledge', + '

' + escapeHtml(article.title || "Untitled article") + '

', + '
', + '', + renderKnowledgeArticleBody(article), + '' + ].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(); + } + }; + } +})(); + diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go index a375617..1e39dc7 100644 --- a/internal/handler/admin_handler.go +++ b/internal/handler/admin_handler.go @@ -3,20 +3,14 @@ package handler import ( "fmt" "net/http" - "xboard-go/internal/database" - "xboard-go/internal/model" + "xboard-go/internal/service" "github.com/gin-gonic/gin" ) func AdminPortal(c *gin.Context) { - // Load settings for the portal - var appNameSetting model.Setting - database.DB.Where("name = ?", "app_name").First(&appNameSetting) - appName := appNameSetting.Value - if appName == "" { - appName = "XBoard Admin" - } + // Load settings for the portal using service to ensure quote normalization + appName := service.MustGetString("app_name", "XBoard Admin") securePath := c.Param("path") if securePath == "" { diff --git a/internal/handler/plugin_api.go b/internal/handler/plugin_api.go index e869845..0e3980a 100644 --- a/internal/handler/plugin_api.go +++ b/internal/handler/plugin_api.go @@ -221,30 +221,38 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) { UserID: user.ID, ShadowUserID: &shadowUser.ID, IPv6Email: shadowUser.Email, - Allowed: !user.Banned, + Allowed: !user.Banned && shadowUser.Banned == 0, Status: "active", UpdatedAt: shadowUser.UpdatedAt, } hasSubscription = true } allowed := service.PluginUserAllowed(&user, user.Plan) - status := "not_allowed" planID := shadowPlanID planNameValue := planNames[shadowPlanID] if hasShadowUser { planID = intFromPointer(shadowUser.PlanID) - planNameValue = planName(shadowUser.Plan) + planNameValue = planNames[planID] + if planNameValue == "" && shadowUser.Plan != nil { + planNameValue = shadowUser.Plan.Name + } } + shadowUserID := 0 shadowUpdatedAt := int64(0) + status := "not_allowed" if hasSubscription { - status = firstString(subscription.Status, status) + status = firstString(subscription.Status, "eligible") if subscription.ShadowUserID != nil { shadowUserID = *subscription.ShadowUserID } shadowUpdatedAt = subscription.UpdatedAt + if hasShadowUser && shadowUser.Banned != 0 { + status = "banned" + } } - effectiveAllowed := allowed || hasSubscription && subscription.Allowed + + effectiveAllowed := allowed || (hasSubscription && subscription.Allowed) status, statusLabel, _ := ipv6StatusPresentation(status, effectiveAllowed) list = append(list, gin.H{ @@ -253,7 +261,7 @@ func AdminIPv6SubscriptionUsers(c *gin.Context) { "plan_id": planID, "plan_name": firstString(planNameValue, "-"), "allowed": effectiveAllowed, - "is_active": hasSubscription && subscription.Status == "active", + "is_active": hasSubscription && status == "active", "status": status, "status_label": statusLabel, "ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)), @@ -346,6 +354,11 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) { return } + var payload struct { + PlanID int `json:"plan_id"` + } + _ = c.ShouldBindJSON(&payload) + var user model.User if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil { Fail(c, 404, "user not found") @@ -356,14 +369,40 @@ func AdminIPv6SubscriptionEnable(c *gin.Context) { return } - if !service.SyncIPv6ShadowAccount(&user) { - Fail(c, 403, "user plan does not support ipv6 subscription") + if !service.SyncIPv6ShadowAccount(&user, payload.PlanID) { + Fail(c, 403, "failed to enable/sync ipv6 subscription") return } SuccessMessage(c, "IPv6 subscription enabled/synced", true) } +func AdminIPv6SubscriptionDisable(c *gin.Context) { + userID := parsePositiveInt(c.Param("userId"), 0) + if userID == 0 { + Fail(c, 400, "invalid user id") + return + } + + // Find shadow user + var shadowUser model.User + if err := database.DB.Where("parent_id = ?", userID).First(&shadowUser).Error; err != nil { + Fail(c, 404, "IPv6 shadow user not found") + return + } + + // Just ban it as per user requirement + if err := database.DB.Model(&model.User{}).Where("id = ?", shadowUser.ID).Update("banned", 1).Error; err != nil { + Fail(c, 500, "failed to ban shadow user") + return + } + + // Update record + _ = database.DB.Model(&model.UserIPv6Subscription{}).Where("user_id = ?", userID).Update("status", "banned") + + SuccessMessage(c, "IPv6 subscription disabled (banned)", true) +} + func AdminIPv6SubscriptionSyncPassword(c *gin.Context) { userID := parsePositiveInt(c.Param("userId"), 0) if userID == 0 { @@ -459,7 +498,6 @@ func PluginUserAddIPv6Check(c *gin.Context) { "status": status, "status_label": statusLabel, "reason": reason, - "ipv6_email": firstString(subscription.IPv6Email, service.IPv6ShadowEmail(user.Email)), }) } @@ -471,10 +509,14 @@ func PluginUserAddIPv6Enable(c *gin.Context) { return } - if !service.SyncIPv6ShadowAccount(user) { - Fail(c, 403, "your plan does not support IPv6 subscription") - return - } + Fail(c, 403, "IPv6 self-service is disabled. Please contact administrator via ticket to enable it.") + return + /* + if !service.SyncIPv6ShadowAccount(user, 0) { + Fail(c, 403, "your plan does not support IPv6 subscription") + return + } + */ payload := gin.H{ "ipv6_email": service.IPv6ShadowEmail(user.Email), diff --git a/internal/handler/realname_handler.go b/internal/handler/realname_handler.go index 6bdeea6..938429d 100644 --- a/internal/handler/realname_handler.go +++ b/internal/handler/realname_handler.go @@ -7,18 +7,14 @@ import ( "time" "xboard-go/internal/database" "xboard-go/internal/model" + "xboard-go/internal/service" "github.com/gin-gonic/gin" ) // RealNameIndex renders the beautified plugin management page. func RealNameIndex(c *gin.Context) { - var appNameSetting model.Setting - database.DB.Where("name = ?", "app_name").First(&appNameSetting) - appName := appNameSetting.Value - if appName == "" { - appName = "XBoard" - } + appName := service.MustGetString("app_name", "XBoard") securePath := c.Param("path") apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath) diff --git a/internal/service/plugin.go b/internal/service/plugin.go index f0153a1..0708be7 100644 --- a/internal/service/plugin.go +++ b/internal/service/plugin.go @@ -73,7 +73,7 @@ func GetPluginConfigBool(code, key string, defaultValue bool) bool { } } -func SyncIPv6ShadowAccount(user *model.User) bool { +func SyncIPv6ShadowAccount(user *model.User, forcedPlanID int) bool { if user == nil { return false } @@ -85,7 +85,9 @@ func SyncIPv6ShadowAccount(user *model.User) bool { plan = &loadedPlan } } - if !PluginUserAllowed(user, plan) { + + // Bypass regular eligibility check if forced by admin + if forcedPlanID == 0 && !PluginUserAllowed(user, plan) { syncIPv6SubscriptionRecord(user, nil, false, "not_allowed") return false } @@ -114,8 +116,15 @@ func SyncIPv6ShadowAccount(user *model.User) bool { ipv6User.D = 0 ipv6User.T = 0 ipv6User.UpdatedAt = now + ipv6User.Banned = 0 // Ensure unbanned when syncing/enabling - if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 { + // Use forced plan or default plugin plan + planID := forcedPlanID + if planID <= 0 { + planID = parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0) + } + + if planID > 0 { ipv6User.PlanID = &planID } if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 { diff --git a/internal/service/settings.go b/internal/service/settings.go index 3594394..ed9b700 100644 --- a/internal/service/settings.go +++ b/internal/service/settings.go @@ -37,7 +37,7 @@ func MustGetString(name, defaultValue string) string { if !ok || strings.TrimSpace(value) == "" { return defaultValue } - return value + return normalizeWrappedString(value) } func MustGetInt(name string, defaultValue int) int {