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.
This commit is contained in:
MHSanaei 2026-05-10 23:40:39 +02:00
parent 737300b14b
commit 745e394c74
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
15 changed files with 97 additions and 82 deletions

View file

@ -22,7 +22,7 @@ function readMetaToken() {
// recurse through this same interceptor. // recurse through this same interceptor.
async function fetchCsrfToken() { async function fetchCsrfToken() {
try { try {
const basePath = window.__X_UI_BASE_PATH__; const basePath = window.X_UI_BASE_PATH;
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/' const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH ? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
: 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.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'; 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 !== '/') { if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath; axios.defaults.baseURL = basePath;
} }
@ -98,7 +98,7 @@ export function setupAxios() {
// the user right back on the dashboard and the interceptor // the user right back on the dashboard and the interceptor
// would loop. Navigate to the dev login entry instead. // would loop. Navigate to the dev login entry instead.
if (import.meta.env.DEV) { 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`; window.location.href = `${basePath}login.html`;
} else { } else {
window.location.reload(); window.location.reload();

View file

@ -140,7 +140,7 @@ export class WebSocketClient {
#buildUrl() { #buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; 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 // by the Go binary in production. In dev (Vite serves directly) the
// global is missing and basePath would be '' — without the fallback to // global is missing and basePath would be '' — without the fallback to
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket // '/' we'd build `ws://host:portws` (no separator) and the WebSocket

View file

@ -9,7 +9,7 @@ let sharedClient = null;
function getSharedClient() { function getSharedClient() {
if (sharedClient) return sharedClient; 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); sharedClient = new WebSocketClient(basePath);
return sharedClient; return sharedClient;
} }

View file

@ -67,7 +67,7 @@ const { isMobile } = useMediaQuery();
// the idnode map for the new "Node" column. Fetched once on mount. // the idnode map for the new "Node" column. Fetched once on mount.
const { byId: nodesById } = useNodeList(); const { byId: nodesById } = useNodeList();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
onMounted(async () => { onMounted(async () => {
@ -631,7 +631,7 @@ function onRowAction({ key, dbInbound }) {
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings" :ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" /> :last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel" <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" /> <TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType" <PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"

View file

@ -1,26 +1,21 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { Protocols } from '@/models/inbound.js'; import { Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel.vue'; import QrPanel from './QrPanel.vue';
const { t } = useI18n(); 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({ const props = defineProps({
open: { type: Boolean, default: false }, open: { type: Boolean, default: false },
dbInbound: { type: Object, default: null }, dbInbound: { type: Object, default: null },
client: { type: Object, default: null }, client: { type: Object, default: null },
remarkModel: { type: String, default: '-ieo' }, 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: '' }, nodeAddress: { type: String, default: '' },
subSettings: {
type: Object,
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
},
}); });
const emit = defineEmits(['update:open']); const emit = defineEmits(['update:open']);
@ -28,6 +23,50 @@ const emit = defineEmits(['update:open']);
const links = ref([]); const links = ref([]);
const wireguardConfigs = ref([]); const wireguardConfigs = ref([]);
const wireguardLinks = 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) => { watch(() => props.open, (next) => {
if (!next || !props.dbInbound) return; if (!next || !props.dbInbound) return;
@ -46,6 +85,21 @@ watch(() => props.open, (next) => {
wireguardConfigs.value = []; wireguardConfigs.value = [];
wireguardLinks.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() { function close() {
@ -56,12 +110,17 @@ function close() {
<template> <template>
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close"> <a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
<template v-if="dbInbound"> <template v-if="dbInbound">
<QrPanel v-for="(link, idx) in links" :key="`l${idx}`" :value="link.link" <a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
:remark="link.remark || `Link ${idx + 1}`" /> <a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
<template v-for="(cfg, idx) in wireguardConfigs" :key="`w${idx}`"> <QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
<QrPanel :value="cfg" :remark="`Peer ${idx + 1} config`" :download-name="`peer-${idx + 1}.conf`" /> </a-collapse-panel>
<QrPanel v-if="wireguardLinks[idx]" :value="wireguardLinks[idx]" :remark="`Peer ${idx + 1} link`" /> </a-collapse>
</template>
</template> </template>
</a-modal> </a-modal>
</template> </template>
<style scoped>
.qr-collapse :deep(.ant-collapse-content-box) {
padding: 8px 0 0;
}
</style>

View file

@ -20,7 +20,7 @@ function exportDb() {
// The Go endpoint streams x-ui.db as a download. Setting // The Go endpoint streams x-ui.db as a download. Setting
// window.location triggers a browser download without leaving // window.location triggers a browser download without leaving
// the page (the Go side responds with Content-Disposition: attachment). // 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() { function importDb() {

View file

@ -53,14 +53,14 @@ onMounted(() => {
}); });
}); });
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; 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 // In dev, Vite serves the HTML directly so the global is missing fall
// back to currentVersion from the panel-update API once it answers. // back to currentVersion from the panel-update API once it answers.
const displayVersion = computed( 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. // Hide/reveal the public IPv4/IPv6 same pattern as legacy.

View file

@ -38,7 +38,7 @@ const user = reactive({
twoFactorCode: '', twoFactorCode: '',
}); });
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
onMounted(async () => { onMounted(async () => {
const msg = await HttpUtil.post('/getTwoFactorEnable'); const msg = await HttpUtil.post('/getTwoFactorEnable');

View file

@ -39,7 +39,7 @@ useWebSocket({ nodes: applyNodesEvent });
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
// === Form modal state ================================================= // === Form modal state =================================================

View file

@ -54,7 +54,7 @@ async function sendUpdateUser() {
if (msg?.success) { if (msg?.success) {
// Force re-login at the standard logout path; basePath is handled // Force re-login at the standard logout path; basePath is handled
// by the Go router so a relative redirect is correct here. // 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`); window.location.replace(`${basePath}logout`);
} }
} finally { } finally {

View file

@ -26,7 +26,7 @@ const { t } = useI18n();
const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting(); const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
// AD-Vue 4's <a-back-top> calls `target()` after mount to find the // AD-Vue 4's <a-back-top> calls `target()` after mount to find the

View file

@ -186,7 +186,7 @@ function onRemoveRoutingRules({ prefix }) {
void message; void message;
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || ''; const basePath = window.X_UI_BASE_PATH || '';
const requestUri = window.location.pathname; const requestUri = window.location.pathname;
// See SettingsPage scrollTarget wrap so `document` is in scope. // See SettingsPage scrollTarget wrap so `document` is in scope.

View file

@ -57,7 +57,7 @@ function refreshBasePath() {
} }
// `apply: 'serve'` keeps the injection out of `vite build` — dist.go // `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() { function injectBasePathPlugin() {
return { return {
name: 'xui-inject-base-path', name: 'xui-inject-base-path',
@ -65,7 +65,7 @@ function injectBasePathPlugin() {
transformIndexHtml(html) { transformIndexHtml(html) {
const basePath = refreshBasePath(); const basePath = refreshBasePath();
const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); 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>`); return html.replace('</head>', `${tag}</head>`);
}, },
}; };

View file

@ -150,7 +150,7 @@ func (a *SUBController) subs(c *gin.Context) {
// serveSubPage renders web/dist/subpage.html for the current subscription // serveSubPage renders web/dist/subpage.html for the current subscription
// request. The Vite-built SPA reads window.__SUB_PAGE_DATA__ on mount — // 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 // page's static asset references resolve correctly when the panel runs
// behind a URL prefix. // behind a URL prefix.
func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageData) { 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) 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>`) `window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
out := bytes.Replace(body, []byte("</head>"), inject, 1) out := bytes.Replace(body, []byte("</head>"), inject, 1)

View file

@ -15,41 +15,14 @@ import (
"github.com/mhsanaei/3x-ui/v3/web/session" "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 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) { func SetDistFS(fs embed.FS) {
distFS = 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() 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) { func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name) body, err := distFS.ReadFile("dist/" + name)
if err != nil { if err != nil {
@ -62,21 +35,11 @@ func serveDistPage(c *gin.Context, name string) {
basePath = "/" 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 != "/" { 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(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+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( jsEscape := strings.NewReplacer(
`\`, `\\`, `\`, `\\`,
`"`, `\"`, `"`, `\"`,
@ -88,13 +51,6 @@ func serveDistPage(c *gin.Context, name string) {
) )
escapedBase := jsEscape.Replace(basePath) escapedBase := jsEscape.Replace(basePath)
escapedVer := jsEscape.Replace(config.GetVersion()) 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) csrfToken, err := session.EnsureCSRFToken(c)
if err != nil { if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err) 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) + `">`) csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
inject := []byte(`<script>window.__X_UI_BASE_PATH__="` + escapedBase + inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase +
`";window.__X_UI_CUR_VER__="` + escapedVer + `";</script>`) `";window.X_UI_CUR_VER="` + escapedVer + `";</script>`)
inject = append(inject, csrfMeta...) inject = append(inject, csrfMeta...)
inject = append(inject, []byte(`</head>`)...) inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1) out := bytes.Replace(body, []byte("</head>"), inject, 1)