From 745e394c7476c538f7011333041a34f244b816fd Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 10 May 2026 23:40:39 +0200 Subject: [PATCH] refactor(panel): rename injected globals + collapse QR modal entries Rename the SPA globals injected by Go to drop the ad-hoc dunder shape and free up the bare `webBasePath` name (still the DB setting key) from colliding with the JS global it used to share: window.__X_UI_BASE_PATH__ -> window.X_UI_BASE_PATH window.__X_UI_CUR_VER__ -> window.X_UI_CUR_VER Also rework the QR-Code modal to fold every QR (subscription + JSON sub URL, share links, WireGuard config/peer links) into a single a-collapse with one panel per QR. Subscription panels are listed first and open by default; everything else stays collapsed so a multi-link inbound no longer scrolls forever. --- frontend/src/api/axios-init.js | 6 +- frontend/src/api/websocket.js | 2 +- frontend/src/composables/useWebSocket.js | 2 +- frontend/src/pages/inbounds/InboundsPage.vue | 4 +- frontend/src/pages/inbounds/QrCodeModal.vue | 91 ++++++++++++++++---- frontend/src/pages/index/BackupModal.vue | 2 +- frontend/src/pages/index/IndexPage.vue | 6 +- frontend/src/pages/login/LoginPage.vue | 2 +- frontend/src/pages/nodes/NodesPage.vue | 2 +- frontend/src/pages/settings/SecurityTab.vue | 2 +- frontend/src/pages/settings/SettingsPage.vue | 2 +- frontend/src/pages/xray/XrayPage.vue | 2 +- frontend/vite.config.js | 4 +- sub/subController.go | 4 +- web/controller/dist.go | 48 +---------- 15 files changed, 97 insertions(+), 82 deletions(-) diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index 66652778..cae11195 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -22,7 +22,7 @@ function readMetaToken() { // recurse through this same interceptor. async function fetchCsrfToken() { try { - const basePath = window.__X_UI_BASE_PATH__; + const basePath = window.X_UI_BASE_PATH; const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/' ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH : CSRF_TOKEN_PATH); @@ -59,7 +59,7 @@ export function setupAxios() { axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'; axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; - const basePath = window.__X_UI_BASE_PATH__; + const basePath = window.X_UI_BASE_PATH; if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') { axios.defaults.baseURL = basePath; } @@ -98,7 +98,7 @@ export function setupAxios() { // the user right back on the dashboard and the interceptor // would loop. Navigate to the dev login entry instead. if (import.meta.env.DEV) { - const basePath = window.__X_UI_BASE_PATH__ || '/'; + const basePath = window.X_UI_BASE_PATH || '/'; window.location.href = `${basePath}login.html`; } else { window.location.reload(); diff --git a/frontend/src/api/websocket.js b/frontend/src/api/websocket.js index 5076e53e..b45eed92 100644 --- a/frontend/src/api/websocket.js +++ b/frontend/src/api/websocket.js @@ -140,7 +140,7 @@ export class WebSocketClient { #buildUrl() { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - // basePath comes from window.__X_UI_BASE_PATH__ which is only injected + // basePath comes from window.X_UI_BASE_PATH which is only injected // by the Go binary in production. In dev (Vite serves directly) the // global is missing and basePath would be '' — without the fallback to // '/' we'd build `ws://host:portws` (no separator) and the WebSocket diff --git a/frontend/src/composables/useWebSocket.js b/frontend/src/composables/useWebSocket.js index 04107afb..27a4eff1 100644 --- a/frontend/src/composables/useWebSocket.js +++ b/frontend/src/composables/useWebSocket.js @@ -9,7 +9,7 @@ let sharedClient = null; function getSharedClient() { if (sharedClient) return sharedClient; - const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || ''; + const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; sharedClient = new WebSocketClient(basePath); return sharedClient; } diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue index fd4fce8b..084f8135 100644 --- a/frontend/src/pages/inbounds/InboundsPage.vue +++ b/frontend/src/pages/inbounds/InboundsPage.vue @@ -67,7 +67,7 @@ const { isMobile } = useMediaQuery(); // the id→node map for the new "Node" column. Fetched once on mount. const { byId: nodesById } = useNodeList(); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; onMounted(async () => { @@ -631,7 +631,7 @@ function onRowAction({ key, dbInbound }) { :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings" :last-online-map="lastOnlineMap" :node-address="infoNodeAddress" /> + :node-address="qrNodeAddress" :sub-settings="subSettings" /> -import { ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { Protocols } from '@/models/inbound.js'; import QrPanel from './QrPanel.vue'; const { t } = useI18n(); - -// Light QR-only modal — used for the "qrcode" row action on -// single-user Shadowsocks and WireGuard inbounds. The big info modal -// (InboundInfoModal) is too detailed when the user just wants the -// share link as a QR. - const props = defineProps({ open: { type: Boolean, default: false }, dbInbound: { type: Object, default: null }, client: { type: Object, default: null }, remarkModel: { type: String, default: '-ieo' }, - // Address of the node hosting this inbound (empty string for local). - // When set, share/QR links use it as the host instead of the panel's - // origin — node-managed inbounds proxy from the node, not the panel. nodeAddress: { type: String, default: '' }, + subSettings: { + type: Object, + default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }), + }, }); const emit = defineEmits(['update:open']); @@ -28,6 +23,50 @@ const emit = defineEmits(['update:open']); const links = ref([]); const wireguardConfigs = ref([]); const wireguardLinks = ref([]); +const subLink = ref(''); +const subJsonLink = ref(''); +const activeKeys = ref([]); + +const qrItems = computed(() => { + const items = []; + if (subLink.value) { + items.push({ + key: 'sub', + header: t('subscription.title'), + value: subLink.value, + }); + } + if (subJsonLink.value) { + items.push({ + key: 'sub-json', + header: `${t('subscription.title')} (JSON)`, + value: subJsonLink.value, + }); + } + links.value.forEach((link, idx) => { + items.push({ + key: `l${idx}`, + header: link.remark || `Link ${idx + 1}`, + value: link.link, + }); + }); + wireguardConfigs.value.forEach((cfg, idx) => { + items.push({ + key: `wc${idx}`, + header: `Peer ${idx + 1} config`, + value: cfg, + downloadName: `peer-${idx + 1}.conf`, + }); + if (wireguardLinks.value[idx]) { + items.push({ + key: `wl${idx}`, + header: `Peer ${idx + 1} link`, + value: wireguardLinks.value[idx], + }); + } + }); + return items; +}); watch(() => props.open, (next) => { if (!next || !props.dbInbound) return; @@ -46,6 +85,21 @@ watch(() => props.open, (next) => { wireguardConfigs.value = []; wireguardLinks.value = []; } + + const subId = props.client?.subId; + if (props.subSettings?.enable && subId) { + subLink.value = (props.subSettings.subURI || '') + subId; + subJsonLink.value = props.subSettings.subJsonEnable + ? (props.subSettings.subJsonURI || '') + subId + : ''; + } else { + subLink.value = ''; + subJsonLink.value = ''; + } + const open = []; + if (subLink.value) open.push('sub'); + if (subJsonLink.value) open.push('sub-json'); + activeKeys.value = open; }); function close() { @@ -56,12 +110,17 @@ function close() { + + diff --git a/frontend/src/pages/index/BackupModal.vue b/frontend/src/pages/index/BackupModal.vue index 4acfaafa..469c3999 100644 --- a/frontend/src/pages/index/BackupModal.vue +++ b/frontend/src/pages/index/BackupModal.vue @@ -20,7 +20,7 @@ function exportDb() { // The Go endpoint streams x-ui.db as a download. Setting // window.location triggers a browser download without leaving // the page (the Go side responds with Content-Disposition: attachment). - window.location = window.__X_UI_BASE_PATH__+'panel/api/server/getDb'; + window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb'; } function importDb() { diff --git a/frontend/src/pages/index/IndexPage.vue b/frontend/src/pages/index/IndexPage.vue index 5f527e18..1c9e28b5 100644 --- a/frontend/src/pages/index/IndexPage.vue +++ b/frontend/src/pages/index/IndexPage.vue @@ -53,14 +53,14 @@ onMounted(() => { }); }); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; -// In production, dist.go injects window.__X_UI_CUR_VER__ at serve time. +// In production, dist.go injects window.X_UI_CUR_VER at serve time. // In dev, Vite serves the HTML directly so the global is missing — fall // back to currentVersion from the panel-update API once it answers. const displayVersion = computed( - () => panelUpdateInfo.value?.currentVersion || window.__X_UI_CUR_VER__ || '?', + () => panelUpdateInfo.value?.currentVersion || window.X_UI_CUR_VER || '?', ); // Hide/reveal the public IPv4/IPv6 — same pattern as legacy. diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue index d24965b5..4954cf1f 100644 --- a/frontend/src/pages/login/LoginPage.vue +++ b/frontend/src/pages/login/LoginPage.vue @@ -38,7 +38,7 @@ const user = reactive({ twoFactorCode: '', }); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; onMounted(async () => { const msg = await HttpUtil.post('/getTwoFactorEnable'); diff --git a/frontend/src/pages/nodes/NodesPage.vue b/frontend/src/pages/nodes/NodesPage.vue index bc8d9fd0..4968d01f 100644 --- a/frontend/src/pages/nodes/NodesPage.vue +++ b/frontend/src/pages/nodes/NodesPage.vue @@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent }); const { isMobile } = useMediaQuery(); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; // === Form modal state ================================================= diff --git a/frontend/src/pages/settings/SecurityTab.vue b/frontend/src/pages/settings/SecurityTab.vue index 9e77c773..d841c787 100644 --- a/frontend/src/pages/settings/SecurityTab.vue +++ b/frontend/src/pages/settings/SecurityTab.vue @@ -54,7 +54,7 @@ async function sendUpdateUser() { if (msg?.success) { // Force re-login at the standard logout path; basePath is handled // by the Go router so a relative redirect is correct here. - const basePath = window.__X_UI_BASE_PATH__ || ''; + const basePath = window.X_UI_BASE_PATH || ''; window.location.replace(`${basePath}logout`); } } finally { diff --git a/frontend/src/pages/settings/SettingsPage.vue b/frontend/src/pages/settings/SettingsPage.vue index 9c0a7e3a..223a609e 100644 --- a/frontend/src/pages/settings/SettingsPage.vue +++ b/frontend/src/pages/settings/SettingsPage.vue @@ -26,7 +26,7 @@ const { t } = useI18n(); const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting(); const { isMobile } = useMediaQuery(); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; // AD-Vue 4's calls `target()` after mount to find the diff --git a/frontend/src/pages/xray/XrayPage.vue b/frontend/src/pages/xray/XrayPage.vue index 674f7937..c1a3ee46 100644 --- a/frontend/src/pages/xray/XrayPage.vue +++ b/frontend/src/pages/xray/XrayPage.vue @@ -186,7 +186,7 @@ function onRemoveRoutingRules({ prefix }) { void message; const { isMobile } = useMediaQuery(); -const basePath = window.__X_UI_BASE_PATH__ || ''; +const basePath = window.X_UI_BASE_PATH || ''; const requestUri = window.location.pathname; // See SettingsPage scrollTarget — wrap so `document` is in scope. diff --git a/frontend/vite.config.js b/frontend/vite.config.js index f1099d38..afc288d8 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -57,7 +57,7 @@ function refreshBasePath() { } // `apply: 'serve'` keeps the injection out of `vite build` — dist.go -// already injects __X_UI_BASE_PATH__ at runtime in production. +// already injects webBasePath at runtime in production. function injectBasePathPlugin() { return { name: 'xui-inject-base-path', @@ -65,7 +65,7 @@ function injectBasePathPlugin() { transformIndexHtml(html) { const basePath = refreshBasePath(); const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); - const tag = ``; + const tag = ``; return html.replace('', `${tag}`); }, }; diff --git a/sub/subController.go b/sub/subController.go index 693b5871..5024e61c 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -150,7 +150,7 @@ func (a *SUBController) subs(c *gin.Context) { // serveSubPage renders web/dist/subpage.html for the current subscription // request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount — -// we inject that here, along with window.__X_UI_BASE_PATH__ so the +// we inject that here, along with window.X_UI_BASE_PATH so the // page's static asset references resolve correctly when the panel runs // behind a URL prefix. func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) { @@ -219,7 +219,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD ) escapedBase := jsEscape.Replace(basePath) - inject := []byte(``) out := bytes.Replace(body, []byte(""), inject, 1) diff --git a/web/controller/dist.go b/web/controller/dist.go index b53e7233..8bcfb84a 100644 --- a/web/controller/dist.go +++ b/web/controller/dist.go @@ -15,41 +15,14 @@ import ( "github.com/mhsanaei/3x-ui/v3/web/session" ) -// distFS is filled in once at startup by the web package via SetDistFS. -// It holds the Vite-built frontend (the `dist/.html` files) so -// the panel's HTML routes can serve them in production. -// -// We can't `go:embed` the dist directory directly from this package -// because embed.FS only accepts paths relative to the source file — -// dist/ lives one directory up. The web package owns the embed and -// hands the FS to us through this setter. var distFS embed.FS -// SetDistFS is called once during server bootstrap by the web package -// to hand off the embedded `dist/` filesystem. func SetDistFS(fs embed.FS) { distFS = fs } -// distPageBuildTime is captured at startup so every served HTML page -// reports a stable Last-Modified header and the browser's conditional -// GETs can hit the 304 path on repeat loads. var distPageBuildTime = time.Now() -// serveDistPage reads `dist/` from the embedded FS and writes it -// to the response. Two transforms run before send: -// -// 1. `` is injected -// just before so the AppSidebar's link generator sees the -// right prefix. -// 2. Absolute Vite-emitted asset URLs (`/assets/...`) are rewritten -// to include the panel's basePath, so installs running under a -// custom URL prefix (e.g. `/myprefix/`) load the bundle from -// `/myprefix/assets/...` where the static handler actually lives. -// -// The HTML responses are served with no-cache so a panel update -// reaches users on the next reload; the long-hashed JS/CSS files -// under /assets/ stay cacheable indefinitely. func serveDistPage(c *gin.Context, name string) { body, err := distFS.ReadFile("dist/" + name) if err != nil { @@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) { basePath = "/" } - // Rewrite asset URLs only when basePath isn't the root — for the - // default `/` install, Vite's `/assets/...` already resolves - // correctly and we save the byte churn. if basePath != "/" { - // Vite emits these three attribute shapes for every entry's - // JS / CSS / modulepreload reference. Anchoring the search to - // the leading attribute name avoids matching unrelated /assets - // substrings inside any inlined script. body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`)) body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`)) } - // Escape just enough that a hostile basePath setting can't break - // out of the JS string literal. The setting is admin-controlled - // but defense-in-depth costs nothing here. jsEscape := strings.NewReplacer( `\`, `\\`, `"`, `\"`, @@ -88,13 +51,6 @@ func serveDistPage(c *gin.Context, name string) { ) escapedBase := jsEscape.Replace(basePath) escapedVer := jsEscape.Replace(config.GetVersion()) - - // Embed a CSRF token in the served HTML the same way the legacy - // templates did via ``. Without this the - // SPA login page has no way to acquire a token (the existing - // /panel/csrf-token endpoint sits behind checkLogin), and POST - // /login is rejected by CSRFMiddleware. EnsureCSRFToken creates - // a session token on first call even for anonymous visitors. csrfToken, err := session.EnsureCSRFToken(c) if err != nil { logger.Warning("Unable to mint CSRF token for", name+":", err) @@ -102,8 +58,8 @@ func serveDistPage(c *gin.Context, name string) { } csrfMeta := []byte(``) - inject := []byte(``) + inject := []byte(``) inject = append(inject, csrfMeta...) inject = append(inject, []byte(``)...) out := bytes.Replace(body, []byte(""), inject, 1)