Parcourir la source

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.
MHSanaei il y a 1 jour
Parent
commit
745e394c74

+ 3 - 3
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();

+ 1 - 1
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

+ 1 - 1
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;
 }

+ 2 - 2
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" />
       <QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
-        :node-address="qrNodeAddress" />
+        :node-address="qrNodeAddress" :sub-settings="subSettings" />
 
       <TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
       <PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"

+ 75 - 16
frontend/src/pages/inbounds/QrCodeModal.vue

@@ -1,26 +1,21 @@
 <script setup>
-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() {
 <template>
   <a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
     <template v-if="dbInbound">
-      <QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link"
-        :remark="link.remark || `Link ${idx + 1}`" />
-      <template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`">
-        <QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" />
-        <QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" />
-      </template>
+      <a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
+        <a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
+          <QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
+        </a-collapse-panel>
+      </a-collapse>
     </template>
   </a-modal>
 </template>
+
+<style scoped>
+.qr-collapse :deep(.ant-collapse-content-box) {
+  padding: 8px 0 0;
+}
+</style>

+ 1 - 1
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() {

+ 3 - 3
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.

+ 1 - 1
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');

+ 1 - 1
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 =================================================

+ 1 - 1
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 {

+ 1 - 1
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 <a-back-top> calls `target()` after mount to find the

+ 1 - 1
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.

+ 2 - 2
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 = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
+      const tag = `<script>window.X_UI_BASE_PATH="${escaped}";</script>`;
       return html.replace('</head>', `${tag}</head>`);
     },
   };

+ 2 - 2
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(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + `";` +
+	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase + `";` +
 		`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)
 

+ 2 - 46
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/<page>.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/<name>` from the embedded FS and writes it
-// to the response. Two transforms run before send:
-//
-//  1. `<script>window.__X_UI_BASE_PATH__ = "..."</script>` is injected
-//     just before </head> 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 `<meta name="csrf-token">`. 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(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
 
-	inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase +
-		`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`)
+	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase +
+		`";window.X_UI_CUR_VER="` + escapedVer + `";</script>`)
 	inject = append(inject, csrfMeta...)
 	inject = append(inject, []byte(`</head>`)...)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)