8 次代码提交 887fca86ec ... 6d732d8d32

作者 SHA1 备注 提交日期
  MHSanaei 6d732d8d32 feat(inbounds): bulk-select clients + UX polish 17 小时之前
  MHSanaei e4900f1bd4 feat(install): add skip-SSL option for reverse-proxy / SSH-tunnel setups 18 小时之前
  MHSanaei 04828246fc feat(frontend): swap QRious for ant-design-vue's a-qrcode 19 小时之前
  MHSanaei c1efc48694 feat(frontend): refresh dark theme + redesign login page 20 小时之前
  MHSanaei f1760b0a28 feat(xray/balancer): restore observatory editor + auto-sync selectors 21 小时之前
  MHSanaei 745e394c74 refactor(panel): rename injected globals + collapse QR modal entries 21 小时之前
  MHSanaei 737300b14b fix(outbound): default VLESS encryption to "none" 22 小时之前
  GRCR13 30469fcd10 fix: backup path with webbasepath (#4223) 22 小时之前
共有 33 个文件被更改,包括 1053 次插入640 次删除
  1. 38 81
      frontend/package-lock.json
  2. 0 2
      frontend/package.json
  3. 3 3
      frontend/src/api/axios-init.js
  4. 1 1
      frontend/src/api/websocket.js
  5. 162 11
      frontend/src/components/AppSidebar.vue
  6. 6 6
      frontend/src/components/DateTimePicker.vue
  7. 17 17
      frontend/src/composables/useTheme.js
  8. 1 1
      frontend/src/composables/useWebSocket.js
  9. 3 3
      frontend/src/models/outbound.js
  10. 7 0
      frontend/src/pages/inbounds/ClientBulkModal.vue
  11. 145 5
      frontend/src/pages/inbounds/ClientRowTable.vue
  12. 3 0
      frontend/src/pages/inbounds/InboundInfoModal.vue
  13. 15 4
      frontend/src/pages/inbounds/InboundList.vue
  14. 62 8
      frontend/src/pages/inbounds/InboundsPage.vue
  15. 75 16
      frontend/src/pages/inbounds/QrCodeModal.vue
  16. 5 69
      frontend/src/pages/inbounds/QrPanel.vue
  17. 3 1
      frontend/src/pages/inbounds/useInbounds.js
  18. 1 1
      frontend/src/pages/index/BackupModal.vue
  19. 5 5
      frontend/src/pages/index/IndexPage.vue
  20. 268 219
      frontend/src/pages/login/LoginPage.vue
  21. 3 3
      frontend/src/pages/nodes/NodesPage.vue
  22. 1 1
      frontend/src/pages/settings/SecurityTab.vue
  23. 3 3
      frontend/src/pages/settings/SettingsPage.vue
  24. 8 69
      frontend/src/pages/settings/TwoFactorModal.vue
  25. 12 34
      frontend/src/pages/sub/SubPage.vue
  26. 124 2
      frontend/src/pages/xray/BalancersTab.vue
  27. 3 3
      frontend/src/pages/xray/XrayPage.vue
  28. 2 3
      frontend/vite.config.js
  29. 52 9
      install.sh
  30. 14 7
      sub/subController.go
  31. 2 46
      web/controller/dist.go
  32. 0 4
      web/controller/server.go
  33. 9 3
      web/service/server.go

+ 38 - 81
frontend/package-lock.json

@@ -1,20 +1,18 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.0.0",
+  "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.0.0",
+      "version": "0.0.1",
       "dependencies": {
         "@ant-design/icons-vue": "^7.0.1",
         "ant-design-vue": "^4.2.6",
         "axios": "^1.7.9",
         "dayjs": "^1.11.20",
-        "moment": "^2.30.1",
         "otpauth": "^9.5.1",
-        "qrious": "^4.0.2",
         "qs": "^6.13.1",
         "vue": "^3.5.13",
         "vue-i18n": "^11.1.4",
@@ -207,42 +205,6 @@
         "node": "^20.19.0 || ^22.13.0 || >=24"
       }
     },
-    "node_modules/@eslint/config-array/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "dev": true,
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/@eslint/config-array/node_modules/brace-expansion": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
-      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
-      "dev": true,
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/@eslint/config-array/node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
-      "dev": true,
-      "dependencies": {
-        "brace-expansion": "^5.0.5"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/@eslint/config-helpers": {
       "version": "0.5.5",
       "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
@@ -968,12 +930,33 @@
         "proxy-from-env": "^2.1.0"
       }
     },
+    "node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
     "node_modules/boolbase": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
       "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
       "dev": true
     },
+    "node_modules/brace-expansion": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
+      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
+      "dev": true,
+      "dependencies": {
+        "balanced-match": "^4.0.2"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -1306,42 +1289,6 @@
         "url": "https://opencollective.com/eslint"
       }
     },
-    "node_modules/eslint/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "dev": true,
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/eslint/node_modules/brace-expansion": {
-      "version": "5.0.6",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
-      "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
-      "dev": true,
-      "dependencies": {
-        "balanced-match": "^4.0.2"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
-    "node_modules/eslint/node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
-      "dev": true,
-      "dependencies": {
-        "brace-expansion": "^5.0.5"
-      },
-      "engines": {
-        "node": "18 || 20 || >=22"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/isaacs"
-      }
-    },
     "node_modules/espree": {
       "version": "11.2.0",
       "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
@@ -2073,6 +2020,21 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/minimatch": {
+      "version": "10.2.5",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+      "dev": true,
+      "dependencies": {
+        "brace-expansion": "^5.0.5"
+      },
+      "engines": {
+        "node": "18 || 20 || >=22"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
     "node_modules/moment": {
       "version": "2.30.1",
       "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -2318,11 +2280,6 @@
         "node": ">=6"
       }
     },
-    "node_modules/qrious": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz",
-      "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g=="
-    },
     "node_modules/qs": {
       "version": "6.15.1",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",

+ 0 - 2
frontend/package.json

@@ -15,9 +15,7 @@
     "ant-design-vue": "^4.2.6",
     "axios": "^1.7.9",
     "dayjs": "^1.11.20",
-    "moment": "^2.30.1",
     "otpauth": "^9.5.1",
-    "qrious": "^4.0.2",
     "qs": "^6.13.1",
     "vue": "^3.5.13",
     "vue-i18n": "^11.1.4",

+ 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

+ 162 - 11
frontend/src/components/AppSidebar.vue

@@ -9,7 +9,7 @@ import {
   ClusterOutlined,
   LogoutOutlined,
   CloseOutlined,
-  MenuFoldOutlined,
+  MenuOutlined,
 } from '@ant-design/icons-vue';
 
 import { currentTheme } from '@/composables/useTheme.js';
@@ -58,11 +58,23 @@ const tabs = computed(() => [
   { key: `${prefix}logout`, icon: 'logout', title: t('logout') },
 ]);
 
+// Logout sits in its own pinned-to-bottom block on the drawer; the
+// remaining items are the navigation proper. The full-height sider on
+// desktop still uses `tabs` as-is so the desktop look is unchanged.
+const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
+const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
+
 const activeTab = ref([props.requestUri]);
 
 const drawerOpen = ref(false);
 const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
 
+// Drawer width is capped against the viewport — AD-Vue's default 378px
+// overflows on narrow phones (e.g. 360px portrait), leaving the page
+// hidden behind the mask. `min()` keeps it sane on both phones and
+// tablets while never exceeding 320px on larger displays.
+const drawerWidth = 'min(82vw, 320px)';
+
 function openLink(key) {
   if (key.startsWith('http')) {
     window.open(key);
@@ -91,6 +103,9 @@ function closeDrawer() {
 <template>
   <div class="ant-sidebar">
     <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
+      <div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
+        {{ collapsed ? '3X' : '3X-UI' }}
+      </div>
       <ThemeSwitch />
       <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
         <a-menu-item v-for="tab in tabs" :key="tab.key">
@@ -101,19 +116,35 @@ function closeDrawer() {
     </a-layout-sider>
 
     <a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
-      :wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
+      :wrap-style="{ padding: 0 }" :width="drawerWidth"
+      :body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
+      :header-style="{ display: 'none' }" @close="closeDrawer">
+      <div class="drawer-header">
+        <span class="drawer-brand">3X-UI</span>
+        <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
+          <CloseOutlined />
+        </button>
+      </div>
       <ThemeSwitch />
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in tabs" :key="tab.key">
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in navTabs" :key="tab.key">
+          <component :is="iconByName[tab.icon]" />
+          <span>{{ tab.title }}</span>
+        </a-menu-item>
+      </a-menu>
+      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
+        @click="({ key }) => openLink(key)">
+        <a-menu-item v-for="tab in utilTabs" :key="tab.key">
           <component :is="iconByName[tab.icon]" />
           <span>{{ tab.title }}</span>
         </a-menu-item>
       </a-menu>
     </a-drawer>
 
-    <button class="drawer-handle" type="button" @click="toggleDrawer">
-      <CloseOutlined v-if="drawerOpen" />
-      <MenuFoldOutlined v-else />
+    <button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
+      @click="toggleDrawer">
+      <MenuOutlined />
     </button>
   </div>
 </template>
@@ -123,21 +154,96 @@ function closeDrawer() {
   height: 100%;
 }
 
+/* `.sider-brand` and `.drawer-brand` share the same light-theme colour
+ * but differ in layout — the sider one is centered with its own
+ * top-of-sidebar padding + border, the drawer one sits inside a flex
+ * header next to the close button. Dark/ultra colour overrides live
+ * in the non-scoped block at the bottom (theme classes attach to
+ * body / html). */
+.sider-brand,
+.drawer-brand {
+  font-weight: 600;
+  font-size: 18px;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.sider-brand {
+  text-align: center;
+  padding: 16px 12px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+  user-select: none;
+}
+
+.sider-brand-collapsed {
+  font-size: 16px;
+  padding: 16px 4px;
+  letter-spacing: 0;
+}
+
 .drawer-handle {
   position: fixed;
-  top: 16px;
-  left: 16px;
+  top: 12px;
+  left: 12px;
   z-index: 1100;
   background: rgba(0, 0, 0, 0.55);
   color: #fff;
   border: none;
-  width: 36px;
-  height: 36px;
+  width: 40px;
+  height: 40px;
   border-radius: 50%;
   cursor: pointer;
   display: none;
   align-items: center;
   justify-content: center;
+  font-size: 18px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 16px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.drawer-close {
+  background: transparent;
+  border: none;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  font-size: 16px;
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.drawer-close:hover,
+.drawer-close:focus-visible {
+  background: rgba(128, 128, 128, 0.18);
+}
+
+.drawer-menu :deep(.ant-menu-item) {
+  height: 48px;
+  line-height: 48px;
+  margin: 0;
+  border-radius: 0;
+}
+
+.drawer-menu :deep(.ant-menu-item .anticon) {
+  font-size: 16px;
+}
+
+/* Push the utility (Logout) block to the bottom of the flex-column
+ * drawer body and separate it from the nav block with a hairline. The
+ * border colour is theme-neutral so it reads on both light and dark. */
+.drawer-utility {
+  margin-top: auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
 }
 
 @media (max-width: 768px) {
@@ -161,3 +267,48 @@ function closeDrawer() {
   }
 }
 </style>
+
+<style>
+/* Non-scoped so the rules survive AD-Vue teleporting the drawer body
+ * outside the AppSidebar element's scope id. Without this the Vue
+ * `:global(body.dark) .drawer-brand` form did not produce the expected
+ * `body.dark .drawer-brand[data-v-xxx]` selector reliably, and the
+ * drawer brand stayed at the light-theme dark colour on the navy
+ * drawer surface. Class names are specific enough that no collision is
+ * expected; AppSidebar owns the only drawer in the app. */
+body.dark .drawer-brand,
+body.dark .sider-brand {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+html[data-theme='ultra-dark'] .drawer-brand,
+html[data-theme='ultra-dark'] .sider-brand {
+  color: #ffffff;
+}
+
+body.dark .drawer-close {
+  color: rgba(255, 255, 255, 0.75);
+}
+
+html[data-theme='ultra-dark'] .drawer-close {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+/* Pin the drawer surface to the same colour the desktop sider uses
+ * (Layout.colorBgHeader / Menu.colorItemBg from useTheme.js) so the
+ * header, empty body region, and menu items read as one continuous
+ * panel. AD-Vue's CSS-in-JS tokens otherwise leave the drawer at
+ * colorBgElevated (#2d2d30 in dark) which clashes with the #252526
+ * menu rows. `!important` is required to beat the CSS-in-JS rule
+ * specificity; AppSidebar owns the only drawer in the app so this
+ * doesn't collide with anything else. */
+body.dark .ant-drawer .ant-drawer-content,
+body.dark .ant-drawer .ant-drawer-body {
+  background: #252526 !important;
+}
+
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
+  background: #0a0a0a !important;
+}
+</style>

+ 6 - 6
frontend/src/components/DateTimePicker.vue

@@ -235,8 +235,8 @@ function onAntChange(next) {
 /* ===== Dark (navy) ======================================================= */
 
 body.dark .persian-datepicker-input {
-  background: #142340;
-  border-color: #1f3358;
+  background: #252526;
+  border-color: #3c3c3c;
   color: rgba(255, 255, 255, 0.88);
 }
 
@@ -251,14 +251,14 @@ body.dark .persian-datepicker-input:focus {
 
 body.dark .vpd-main .vpd-icon-btn {
   background: rgba(255, 255, 255, 0.04) !important;
-  border: 1px solid #1f3358 !important;
+  border: 1px solid #3c3c3c !important;
   border-right: none !important;
   border-radius: 6px 0 0 6px !important;
   color: rgba(255, 255, 255, 0.75) !important;
 }
 
 body.dark .vpd-wrapper .vpd-content {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
   box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
     0 3px 6px -4px rgba(0, 0, 0, 0.48),
@@ -266,7 +266,7 @@ body.dark .vpd-wrapper .vpd-content {
 }
 
 body.dark .vpd-wrapper .vpd-body {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
 }
 
@@ -315,7 +315,7 @@ body.dark .vpd-wrapper .vpd-actions button:hover {
 
 body.dark .vpd-wrapper .vpd-addon-list,
 body.dark .vpd-wrapper .vpd-addon-list-content {
-  background: #1a2c4d;
+  background: #2d2d30;
   color: rgba(255, 255, 255, 0.88);
 }
 

+ 17 - 17
frontend/src/composables/useTheme.js

@@ -16,7 +16,7 @@ function readBool(key, fallback) {
 }
 
 const isDark = readBool(STORAGE_DARK, true);
-const isUltra = readBool(STORAGE_ULTRA, false);
+const isUltra = readBool(STORAGE_ULTRA, true);
 
 export const theme = reactive({
   isDark,
@@ -27,14 +27,15 @@ export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
 
 // AD-Vue 4 theme config consumed by every page's <a-config-provider>.
 // Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
-// blue primary. Dark uses a navy palette across page/cards/modals so
-// the sidebar blends with the rest of the surface; ultra-dark stays
-// neutral black on top of darkAlgorithm.
+// blue primary. Dark uses a neutral grey palette modelled on VS Code's
+// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
+// so the panel reads as a familiar modern IDE rather than the older
+// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
 const DARK_TOKENS = {
-  colorBgBase: '#0a1426',
-  colorBgLayout: '#0a1426',
-  colorBgContainer: '#142340',
-  colorBgElevated: '#1a2c4d',
+  colorBgBase: '#1e1e1e',
+  colorBgLayout: '#1e1e1e',
+  colorBgContainer: '#252526',
+  colorBgElevated: '#2d2d30',
 };
 const ULTRA_DARK_TOKENS = {
   colorBgBase: '#000',
@@ -47,13 +48,12 @@ const ULTRA_DARK_TOKENS = {
 // + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
 // backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
 // index.js). Override at the component-token level so the sider blends
-// with darkAlgorithm's neutral surfaces.
-// Dark theme uses a refined navy for the sidebar — distinct from the
-// neutral ultra-dark and warmer than AD-Vue's stock #001529.
+// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
+// `#252526` / `#333333` tones VS Code does for its activity bar.
 const DARK_LAYOUT_TOKENS = {
-  colorBgHeader: '#0d1d33',
-  colorBgTrigger: '#15294a',
-  colorBgBody: '#000',
+  colorBgHeader: '#252526',
+  colorBgTrigger: '#333333',
+  colorBgBody: '#1e1e1e',
 };
 const ULTRA_DARK_LAYOUT_TOKENS = {
   colorBgHeader: '#0a0a0a',
@@ -61,9 +61,9 @@ const ULTRA_DARK_LAYOUT_TOKENS = {
   colorBgBody: '#000',
 };
 const DARK_MENU_TOKENS = {
-  colorItemBg: '#0d1d33',
-  colorSubItemBg: '#08142a',
-  menuSubMenuBg: '#0d1d33',
+  colorItemBg: '#252526',
+  colorSubItemBg: '#1e1e1e',
+  menuSubMenuBg: '#252526',
 };
 const ULTRA_DARK_MENU_TOKENS = {
   colorItemBg: '#0a0a0a',

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

+ 3 - 3
frontend/src/models/outbound.js

@@ -1926,13 +1926,13 @@ Outbound.VmessSettings = class extends CommonClass {
     }
 };
 Outbound.VLESSSettings = class extends CommonClass {
-    constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
+    constructor(address, port, id, flow, encryption = 'none', reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
         super();
         this.address = address;
         this.port = port;
         this.id = id;
         this.flow = flow;
-        this.encryption = encryption;
+        this.encryption = encryption || 'none';
         this.reverseTag = reverseTag;
         this.reverseSniffing = reverseSniffing;
         this.testpre = testpre;
@@ -1966,7 +1966,7 @@ Outbound.VLESSSettings = class extends CommonClass {
             port: this.port,
             id: this.id,
             flow: this.flow,
-            encryption: this.encryption,
+            encryption: this.encryption || 'none',
         };
         if (!ObjectUtil.isEmpty(this.reverseTag)) {
             const reverseSniffing = this.reverseSniffing ? this.reverseSniffing.toJson() : {};

+ 7 - 0
frontend/src/pages/inbounds/ClientBulkModal.vue

@@ -53,6 +53,7 @@ const form = reactive({
   flow: '',
   subId: '',
   tgId: 0,
+  comment: '',
   limitIp: 0,
   totalGB: 0,
   expiryTime: 0, // ms epoch; negative => delayed start days
@@ -85,6 +86,7 @@ watch(() => props.open, (next) => {
   form.flow = '';
   form.subId = '';
   form.tgId = 0;
+  form.comment = '';
   form.limitIp = 0;
   form.totalGB = 0;
   form.expiryTime = 0;
@@ -135,6 +137,7 @@ function buildClients() {
 
     if (form.subId.length > 0) c.subId = form.subId;
     c.tgId = form.tgId;
+    if (form.comment.length > 0) c.comment = form.comment;
     c.security = form.security;
     c.limitIp = form.limitIp;
     // Use the clien's totalGB setter (ms epoch and bytes already handled
@@ -227,6 +230,10 @@ async function submit() {
         <a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
       </a-form-item>
 
+      <a-form-item :label="t('comment')">
+        <a-input v-model:value="form.comment" />
+      </a-form-item>
+
       <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
         <a-input-number v-model:value="form.limitIp" :min="0" />
       </a-form-item>

+ 145 - 5
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   EditOutlined,
@@ -39,6 +39,7 @@ const emit = defineEmits([
   'info-client',
   'reset-traffic-client',
   'delete-client',
+  'delete-clients',
   'toggle-enable-client',
 ]);
 
@@ -162,23 +163,95 @@ function confirmDelete(client) {
 function rowKey(client) {
   return client.email || client.id || client.password || JSON.stringify(client);
 }
+
+const selected = ref(new Set());
+
+const allSelected = computed(() =>
+  clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
+);
+const someSelected = computed(() =>
+  clients.value.some((c) => selected.value.has(rowKey(c))),
+);
+const selectedCount = computed(() => selected.value.size);
+
+function isSelected(key) {
+  return selected.value.has(key);
+}
+function toggleSelect(key, next) {
+  const s = new Set(selected.value);
+  if (next) s.add(key); else s.delete(key);
+  selected.value = s;
+}
+function selectAll(next) {
+  if (next) {
+    selected.value = new Set(clients.value.map(rowKey));
+  } else {
+    selected.value = new Set();
+  }
+}
+function clearSelection() {
+  selected.value = new Set();
+}
+
+watch(clients, (list) => {
+  if (selected.value.size === 0) return;
+  const valid = new Set(list.map(rowKey));
+  const next = new Set();
+  for (const k of selected.value) if (valid.has(k)) next.add(k);
+  if (next.size !== selected.value.size) selected.value = next;
+});
+
+function confirmBulkDelete() {
+  const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
+  if (picked.length === 0) return;
+  Modal.confirm({
+    title: t('pages.inbounds.deleteClient') + ` — ${picked.length}`,
+    content: t('pages.inbounds.deleteClientContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: () => {
+      emit('delete-clients', { dbInbound: props.dbInbound, clients: picked });
+      clearSelection();
+    },
+  });
+}
 </script>
 
 <template>
-  <div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
+  <div class="client-list"
+    :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
+    <div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
+      <span class="bulk-count">{{ selectedCount }} selected</span>
+      <a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
+      <a-button size="small" danger @click="confirmBulkDelete">
+        <DeleteOutlined /> {{ t('delete') }}
+      </a-button>
+    </div>
+
     <!-- ====================== Desktop: grid table ===================== -->
     <template v-if="!isMobile">
       <div class="client-row client-list-header">
+        <div v-if="isRemovable" class="cell cell-select">
+          <a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
+            @change="(e) => selectAll(e.target.checked)" />
+        </div>
         <div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
         <div class="cell cell-enable">{{ t('enable') }}</div>
         <div class="cell cell-online">{{ t('online') }}</div>
         <div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
         <div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
+        <div class="cell cell-remained">{{ t('remained') }}</div>
         <div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
         <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
       </div>
 
-      <div v-for="client in clients" :key="rowKey(client)" class="client-row">
+      <div v-for="client in clients" :key="rowKey(client)" class="client-row"
+        :class="{ 'is-selected': isSelected(rowKey(client)) }">
+        <div v-if="isRemovable" class="cell cell-select">
+          <a-checkbox :checked="isSelected(rowKey(client))"
+            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
+        </div>
         <div class="cell cell-actions">
           <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
             <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
@@ -262,6 +335,15 @@ function rowKey(client) {
           </a-popover>
         </div>
 
+        <div class="cell cell-remained">
+          <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
+            <InfinityIcon />
+          </a-tag>
+          <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
+            {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
+          </a-tag>
+        </div>
+
         <div class="cell cell-alltime">
           <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
         </div>
@@ -301,8 +383,11 @@ function rowKey(client) {
 
     <!-- ====================== Mobile: card list ======================= -->
     <template v-else>
-      <div v-for="client in clients" :key="rowKey(client)" class="client-card">
+      <div v-for="client in clients" :key="rowKey(client)" class="client-card"
+        :class="{ 'is-selected': isSelected(rowKey(client)) }">
         <div class="client-card-head">
+          <a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
+            @change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
           <a-tooltip>
             <template #title>
               <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
@@ -356,6 +441,15 @@ function rowKey(client) {
               <template v-else>{{ totalGbDisplay(client) }}</template>
             </a-tag>
           </div>
+          <div class="stat-row">
+            <span class="stat-label">{{ t('remained') }}</span>
+            <a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
+              <InfinityIcon />
+            </a-tag>
+            <a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
+              {{ SizeFormatter.sizeFormat(getRem(client.email)) }}
+            </a-tag>
+          </div>
           <div class="stat-row">
             <span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
             <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
@@ -389,8 +483,28 @@ function rowKey(client) {
   font-size: 13px;
 }
 
+.bulk-bar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 6px 16px;
+  background: rgba(22, 119, 255, 0.08);
+  border-bottom: 1px solid rgba(22, 119, 255, 0.18);
+}
+
+.bulk-count {
+  font-weight: 500;
+  font-size: 13px;
+}
+
+.is-selected {
+  background: rgba(22, 119, 255, 0.06);
+}
+
 .client-row {
   display: grid;
+  /* Default — no select column (single-client inbounds). The .has-select
+   * modifier below prepends the 40px checkbox column. */
   grid-template-columns:
     140px
     /* actions */
@@ -404,6 +518,8 @@ function rowKey(client) {
     /* traffic */
     130px
     /* all-time */
+    130px
+    /* remained */
     140px;
   /* expiry */
   gap: 12px;
@@ -412,6 +528,28 @@ function rowKey(client) {
   border-top: 1px solid rgba(128, 128, 128, 0.12);
 }
 
+.client-list.has-select .client-row {
+  grid-template-columns:
+    40px
+    /* select */
+    140px
+    /* actions */
+    60px
+    /* enable */
+    80px
+    /* online */
+    minmax(160px, 2fr)
+    /* client identity */
+    minmax(160px, 2fr)
+    /* traffic */
+    130px
+    /* all-time */
+    130px
+    /* remained */
+    140px;
+  /* expiry */
+}
+
 .client-row:last-child {
   border-bottom: 1px solid rgba(128, 128, 128, 0.12);
 }
@@ -432,10 +570,12 @@ function rowKey(client) {
   /* allow grid children to shrink instead of overflowing */
 }
 
+.cell-select,
 .cell-actions,
 .cell-enable,
 .cell-online,
-.cell-alltime {
+.cell-alltime,
+.cell-remained {
   text-align: center;
   display: inline-flex;
   align-items: center;

+ 3 - 0
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -387,6 +387,9 @@ const showSubscriptionTab = computed(
                 <td>
                   <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
                     getRemainingStats() }}</a-tag>
+                  <a-tag v-else-if="!clientSettings.totalGB || clientSettings.totalGB <= 0" color="purple">
+                    <InfinityIcon />
+                  </a-tag>
                 </td>
                 <td>
                   <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{

+ 15 - 4
frontend/src/pages/inbounds/InboundList.vue

@@ -62,6 +62,7 @@ const emit = defineEmits([
   'info-client',
   'reset-traffic-client',
   'delete-client',
+  'delete-clients',
   'toggle-enable-client',
 ]);
 
@@ -404,6 +405,7 @@ function showQrCodeMenu(dbInbound) {
               @info-client="(p) => emit('info-client', p)"
               @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
               @delete-client="(p) => emit('delete-client', p)"
+              @delete-clients="(p) => emit('delete-clients', p)"
               @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
           </div>
         </div>
@@ -423,6 +425,7 @@ function showQrCodeMenu(dbInbound) {
             @qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
             @reset-traffic-client="(p) => emit('reset-traffic-client', p)"
             @delete-client="(p) => emit('delete-client', p)"
+            @delete-clients="(p) => emit('delete-clients', p)"
             @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
         </template>
 
@@ -523,27 +526,35 @@ function showQrCodeMenu(dbInbound) {
               <a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
               <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
                 <template #content>
-                  <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
+                  <div class="client-email-list">
+                    <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
+                  </div>
                 </template>
                 <a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
                 <template #content>
-                  <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
+                  <div class="client-email-list">
+                    <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
+                  </div>
                 </template>
                 <a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
                 }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
                 <template #content>
-                  <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
+                  <div class="client-email-list">
+                    <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
+                  </div>
                 </template>
                 <a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
                 }}</a-tag>
               </a-popover>
               <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
                 <template #content>
-                  <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
+                  <div class="client-email-list">
+                    <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
+                  </div>
                 </template>
                 <a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
               </a-popover>

+ 62 - 8
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 () => {
@@ -322,6 +322,14 @@ async function onDeleteClient({ dbInbound, client }) {
   if (msg?.success) await refresh();
 }
 
+async function onDeleteClients({ dbInbound, clients }) {
+  for (const client of clients) {
+    const clientId = getClientId(dbInbound.protocol, client);
+    await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
+  }
+  await refresh();
+}
+
 async function onToggleEnableClient({ dbInbound, client, next }) {
   // Mirror legacy: clone the parsed inbound, flip enable on the matching
   // client, and post the whole client back through updateClient. This
@@ -593,9 +601,38 @@ function onRowAction({ key, dbInbound }) {
                           <a-space direction="horizontal">
                             <TeamOutlined />
                             <a-tag color="green">{{ totals.clients }}</a-tag>
-                            <a-tag v-if="totals.deactive.length">{{ totals.deactive.length }}</a-tag>
-                            <a-tag v-if="totals.depleted.length" color="red">{{ totals.depleted.length }}</a-tag>
-                            <a-tag v-if="totals.expiring.length" color="orange">{{ totals.expiring.length }}</a-tag>
+                            <a-popover v-if="totals.deactive.length" :title="t('disabled')">
+                              <template #content>
+                                <div class="client-email-list">
+                                  <div v-for="email in totals.deactive" :key="email">{{ email }}</div>
+                                </div>
+                              </template>
+                              <a-tag>{{ totals.deactive.length }}</a-tag>
+                            </a-popover>
+                            <a-popover v-if="totals.depleted.length" :title="t('depleted')">
+                              <template #content>
+                                <div class="client-email-list">
+                                  <div v-for="email in totals.depleted" :key="email">{{ email }}</div>
+                                </div>
+                              </template>
+                              <a-tag color="red">{{ totals.depleted.length }}</a-tag>
+                            </a-popover>
+                            <a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
+                              <template #content>
+                                <div class="client-email-list">
+                                  <div v-for="email in totals.expiring" :key="email">{{ email }}</div>
+                                </div>
+                              </template>
+                              <a-tag color="orange">{{ totals.expiring.length }}</a-tag>
+                            </a-popover>
+                            <a-popover v-if="totals.online.length" :title="t('online')">
+                              <template #content>
+                                <div class="client-email-list">
+                                  <div v-for="email in totals.online" :key="email">{{ email }}</div>
+                                </div>
+                              </template>
+                              <a-tag color="blue">{{ totals.online.length }}</a-tag>
+                            </a-popover>
                           </a-space>
                         </template>
                       </CustomStatistic>
@@ -613,7 +650,7 @@ function onRowAction({ key, dbInbound }) {
                   @add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
                   @edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
                   @reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
-                  @toggle-enable-client="onToggleEnableClient" />
+                  @delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
               </a-col>
             </a-row>
           </a-spin>
@@ -631,7 +668,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"
@@ -650,8 +687,8 @@ function onRowAction({ key, dbInbound }) {
 }
 
 .inbounds-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .inbounds-page.is-dark.is-ultra {
@@ -692,3 +729,20 @@ function onRowAction({ key, dbInbound }) {
   }
 }
 </style>
+
+<style>
+/* AD-Vue popovers teleport their content to <body>, so scoped styles
+   don't reach them — this block has to be unscoped. */
+.client-email-list {
+  max-height: 280px;
+  min-width: 160px;
+  overflow-y: auto;
+  padding-right: 4px;
+}
+
+.client-email-list > div {
+  padding: 2px 0;
+  font-size: 12px;
+  white-space: nowrap;
+}
+</style>

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

+ 5 - 69
frontend/src/pages/inbounds/QrPanel.vue

@@ -1,7 +1,5 @@
 <script setup>
-import { onMounted, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
-import QRious from 'qrious';
 import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
 import { message } from 'ant-design-vue';
 
@@ -9,73 +7,14 @@ import { ClipboardManager, FileManager } from '@/utils';
 
 const { t } = useI18n();
 
-// Renders a single share-link as a clickable QR code + a copy button
-// + (optional) a download button. Used per-link inside the inbound
-// info modal — the canvas is repainted whenever `value` changes.
-
 const props = defineProps({
-  // The link or config text to encode + display.
   value: { type: String, required: true },
-  // Header label shown next to the copy button.
   remark: { type: String, default: '' },
-  // Optional download filename — when set, surfaces a download button.
   downloadName: { type: String, default: '' },
-  // Final on-screen QR size in CSS pixels. The canvas drawing buffer
-  // is rounded down to a multiple of the QR matrix width (so the QR
-  // fills it edge-to-edge) and CSS then scales the canvas to exactly
-  // this size — so a denser QR (e.g. WireGuard config) and a sparser
-  // one (its link) display at identical dimensions.
   size: { type: Number, default: 240 },
-  // Toggle the QR rendering off when callers only want the "row of buttons"
-  // styling (used when the legacy panel rendered links without QRs).
   showQr: { type: Boolean, default: true },
 });
 
-const canvas = ref(null);
-
-// Byte-mode capacities (level M) for QR versions 1..40 — used to pick
-// the matrix width up front so we can size the canvas as a multiple
-// of pixelSize. Without this, QRious renders at floor(size/matrix)
-// and centers, leaving a white margin whenever size isn't divisible.
-const QR_M_BYTE_CAPACITY = [
-  14, 26, 42, 62, 84, 106, 122, 152, 180, 213,
-  251, 287, 331, 362, 412, 450, 504, 560, 624, 666,
-  711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370,
-  1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331,
-];
-
-function pickQrMatrixWidth(value) {
-  const byteLen = new TextEncoder().encode(value).length;
-  for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) {
-    if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
-  }
-  return 17 + 4 * 40; // version 40 (177 modules)
-}
-
-function paint() {
-  if (!props.showQr || !canvas.value || !props.value) return;
-  // Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to-
-  // edge. pixelSize is floored against the requested size so the QR
-  // never grows past the host's expected box.
-  const matrixWidth = pickQrMatrixWidth(props.value);
-  const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth));
-  const exactSize = matrixWidth * pixelSize;
-  new QRious({
-    element: canvas.value,
-    size: exactSize,
-    value: props.value,
-    background: 'white',
-    backgroundAlpha: 1,
-    foreground: 'black',
-    padding: 0,
-    level: 'M',
-  });
-}
-
-onMounted(paint);
-watch(() => props.value, paint);
-watch(() => props.size, paint);
-
 async function copy() {
   const ok = await ClipboardManager.copyText(props.value);
   if (ok) message.success(t('copied'));
@@ -107,7 +46,8 @@ function download() {
       </a-tooltip>
     </div>
     <div v-if="showQr" class="qr-panel-canvas">
-      <canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
+      <a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
+        :title="t('copy')" @click="copy" />
     </div>
   </div>
 </template>
@@ -140,14 +80,10 @@ function download() {
   padding: 6px 0;
 }
 
-.qr-panel-canvas canvas {
+.qr-panel-canvas .qr-code {
   cursor: pointer;
-  display: block;
+  padding: 0 !important;
+  background: #fff;
   border-radius: 4px;
-  /* Drawing buffer is matrix-snapped (smaller than display size for
-   * dense QRs); scale up crisply so dense and sparse QRs share the
-   * same on-screen footprint without blurring. */
-  image-rendering: pixelated;
-  image-rendering: crisp-edges;
 }
 </style>

+ 3 - 1
frontend/src/pages/inbounds/useInbounds.js

@@ -287,6 +287,7 @@ export function useInbounds() {
     const deactive = [];
     const depleted = [];
     const expiring = [];
+    const online = [];
     for (const ib of dbInbounds.value) {
       up += ib.up || 0;
       down += ib.down || 0;
@@ -297,9 +298,10 @@ export function useInbounds() {
         deactive.push(...c.deactive);
         depleted.push(...c.depleted);
         expiring.push(...c.expiring);
+        online.push(...c.online);
       }
     }
-    return { up, down, allTime, clients, deactive, depleted, expiring };
+    return { up, down, allTime, clients, deactive, depleted, expiring, online };
   });
 
   // ObjectUtil reference is wired at module load — keeping a no-op import

+ 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 = '/panel/api/server/getDb';
+  window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
 }
 
 function importDb() {

+ 5 - 5
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.
@@ -336,8 +336,8 @@ async function openConfig() {
 }
 
 .index-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .index-page.is-dark.is-ultra {

+ 268 - 219
frontend/src/pages/login/LoginPage.vue

@@ -13,6 +13,19 @@ import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
 
 const { t } = useI18n();
 
+const fetched = ref(false);
+const submitting = ref(false);
+const twoFactorEnable = ref(false);
+const version = computed(() => window.X_UI_CUR_VER || '');
+
+const user = reactive({
+  username: '',
+  password: '',
+  twoFactorCode: '',
+});
+
+const basePath = window.X_UI_BASE_PATH || '';
+
 const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
 const HEADLINE_INTERVAL_MS = 2000;
 const headlineIndex = ref(0);
@@ -28,23 +41,9 @@ onBeforeUnmount(() => {
   if (headlineTimer != null) window.clearInterval(headlineTimer);
 });
 
-const fetched = ref(false);
-const submitting = ref(false);
-const twoFactorEnable = ref(false);
-
-const user = reactive({
-  username: '',
-  password: '',
-  twoFactorCode: '',
-});
-
-const basePath = window.__X_UI_BASE_PATH__ || '';
-
 onMounted(async () => {
   const msg = await HttpUtil.post('/getTwoFactorEnable');
-  if (msg.success) {
-    twoFactorEnable.value = !!msg.obj;
-  }
+  if (msg.success) twoFactorEnable.value = !!msg.obj;
   fetched.value = true;
 });
 
@@ -52,9 +51,7 @@ async function login() {
   submitting.value = true;
   try {
     const msg = await HttpUtil.post('/login', user);
-    if (msg.success) {
-      window.location.href = basePath + 'panel/';
-    }
+    if (msg.success) window.location.href = basePath + 'panel/';
   } finally {
     submitting.value = false;
   }
@@ -70,102 +67,85 @@ function onLangChange(next) {
   <a-config-provider :theme="antdThemeConfig">
     <a-layout class="login-app" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
       <a-layout-content class="login-content">
-        <div class="waves-header">
-          <div class="waves-inner-header"></div>
-          <svg class="waves" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
-            viewBox="0 24 150 28" preserveAspectRatio="none" shape-rendering="auto">
-            <defs>
-              <path id="gentle-wave" d="M-160 44c30 0 58-18 88-18s 58 18 88 18 58-18 88-18 58 18 88 18 v44h-352z" />
-            </defs>
-            <g class="parallax">
-              <use xlink:href="#gentle-wave" x="48" y="0" />
-              <use xlink:href="#gentle-wave" x="48" y="3" />
-              <use xlink:href="#gentle-wave" x="48" y="5" />
-              <use xlink:href="#gentle-wave" x="48" y="7" />
-            </g>
-          </svg>
+        <!-- Floating settings (theme switcher + language picker) sits in
+             the viewport's top-right corner so the card stays uncluttered. -->
+        <div class="login-toolbar">
+          <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
+            trigger="click">
+            <template #content>
+              <a-space direction="vertical" :size="10" class="settings-popover">
+                <ThemeSwitchLogin />
+                <span>{{ t('pages.settings.language') }}</span>
+                <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
+                  <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value">
+                    <span :aria-label="l.name">{{ l.icon }}</span>
+                    &nbsp;&nbsp;<span>{{ l.name }}</span>
+                  </a-select-option>
+                </a-select>
+              </a-space>
+            </template>
+            <a-button shape="circle" class="toolbar-btn" :aria-label="t('menu.settings')">
+              <template #icon>
+                <SettingOutlined />
+              </template>
+            </a-button>
+          </a-popover>
         </div>
 
-        <a-row type="flex" justify="center" align="middle" class="login-row">
-          <a-col class="login-card">
-            <div v-if="!fetched" class="login-loading">
-              <a-spin size="large" />
-            </div>
+        <div class="login-wrapper">
+          <div v-if="!fetched" class="login-loading">
+            <a-spin size="large" />
+          </div>
 
-            <div v-else>
-              <div class="login-settings">
-                <a-popover :overlay-class-name="currentTheme" :title="t('menu.settings')" placement="bottomRight"
-                  trigger="click">
-                  <template #content>
-                    <a-space direction="vertical" :size="10" class="settings-popover">
-                      <ThemeSwitchLogin />
-                      <span>{{ t('pages.settings.language') }}</span>
-                      <a-select v-model:value="lang" class="lang-select" @change="onLangChange">
-                        <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
-                          :value="l.value">
-                          <span :aria-label="l.name">{{ l.icon }}</span>
-                          &nbsp;&nbsp;<span>{{ l.name }}</span>
-                        </a-select-option>
-                      </a-select>
-                    </a-space>
-                  </template>
-                  <a-button shape="circle">
-                    <template #icon>
-                      <SettingOutlined />
-                    </template>
-                  </a-button>
-                </a-popover>
-              </div>
-
-              <a-row justify="center">
-                <a-col :span="24">
-                  <h2 class="login-title">
-                    <Transition name="headline" mode="out-in">
-                      <b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
-                    </Transition>
-                  </h2>
-                </a-col>
-              </a-row>
-
-              <a-form layout="vertical" @submit.prevent="login">
-                <a-form-item>
-                  <a-input v-model:value="user.username" autocomplete="username" name="username"
-                    :placeholder="t('username')" autofocus required>
-                    <template #prefix>
-                      <UserOutlined />
-                    </template>
-                  </a-input>
-                </a-form-item>
-
-                <a-form-item>
-                  <a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
-                    :placeholder="t('password')" required>
-                    <template #prefix>
-                      <LockOutlined />
-                    </template>
-                  </a-input-password>
-                </a-form-item>
-
-                <a-form-item v-if="twoFactorEnable">
-                  <a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
-                    :placeholder="t('twoFactorCode')" required>
-                    <template #prefix>
-                      <KeyOutlined />
-                    </template>
-                  </a-input>
-                </a-form-item>
-
-                <a-form-item>
-                  <a-row justify="center">
-                    <a-button type="primary" html-type="submit" :loading="submitting" block>
-                      {{ submitting ? '' : t('login') }}
-                    </a-button>
-                  </a-row>
-                </a-form-item>
-              </a-form>
+          <div v-else class="login-card">
+            <div class="brand">
+              <span class="brand-name">3X-UI</span>
+              <span class="brand-accent" aria-hidden="true"></span>
             </div>
-          </a-col>
-        </a-row>
+            <h2 class="welcome">
+              <Transition name="headline" mode="out-in">
+                <b :key="headlineIndex">{{ headlineWords[headlineIndex] }}</b>
+              </Transition>
+            </h2>
+
+            <a-form layout="vertical" class="login-form" @submit.prevent="login">
+              <a-form-item :label="t('username')">
+                <a-input v-model:value="user.username" autocomplete="username" name="username" size="large"
+                  :placeholder="t('username')" autofocus required>
+                  <template #prefix>
+                    <UserOutlined />
+                  </template>
+                </a-input>
+              </a-form-item>
+
+              <a-form-item :label="t('password')">
+                <a-input-password v-model:value="user.password" autocomplete="current-password" name="password"
+                  size="large" :placeholder="t('password')" required>
+                  <template #prefix>
+                    <LockOutlined />
+                  </template>
+                </a-input-password>
+              </a-form-item>
+
+              <a-form-item v-if="twoFactorEnable" :label="t('twoFactorCode')">
+                <a-input v-model:value="user.twoFactorCode" autocomplete="one-time-code" name="twoFactorCode"
+                  size="large" :placeholder="t('twoFactorCode')" required>
+                  <template #prefix>
+                    <KeyOutlined />
+                  </template>
+                </a-input>
+              </a-form-item>
+
+              <a-form-item class="submit-row">
+                <a-button type="primary" html-type="submit" :loading="submitting" size="large" block>
+                  {{ submitting ? '' : t('login') }}
+                </a-button>
+              </a-form-item>
+            </a-form>
+
+            <div v-if="version" class="version">v{{ version }}</div>
+          </div>
+        </div>
       </a-layout-content>
     </a-layout>
   </a-config-provider>
@@ -173,178 +153,247 @@ function onLangChange(next) {
 
 <style scoped>
 .login-app {
-  --bg-page: #c7ebe2;
-  --bg-wave-header: #dbf5ed;
+  --bg-page: #f5f7fa;
   --bg-card: #ffffff;
-  --color-title: #008771;
-  --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.09);
-  --wave-fill: rgba(0, 135, 113, 0.12);
-  --wave-fill-bottom: #c7ebe2;
+  --color-text: rgba(0, 0, 0, 0.88);
+  --color-text-subtle: rgba(0, 0, 0, 0.55);
+  --color-accent: #1677ff;
+  --color-border: rgba(0, 0, 0, 0.08);
+  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.06);
+  --blob-1: rgba(99, 102, 241, 0.45);
+  --blob-2: rgba(236, 72, 153, 0.38);
+  --blob-3: rgba(20, 184, 166, 0.32);
 
+  position: relative;
   min-height: 100vh;
+  overflow: hidden;
+  background: var(--bg-page);
 }
 
 .login-app.is-dark {
-  --bg-page: #222d42;
-  --bg-wave-header: #0a1222;
-  --bg-card: #151f31;
-  --color-title: rgba(255, 255, 255, 0.92);
-  --shadow-card: 0 4px 16px rgba(0, 0, 0, 0.45);
-  --wave-fill: #222d42;
-  --wave-fill-bottom: #222d42;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
+  --color-text: rgba(255, 255, 255, 0.92);
+  --color-text-subtle: rgba(255, 255, 255, 0.55);
+  --color-accent: #4096ff;
+  --color-border: rgba(255, 255, 255, 0.08);
+  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 8px 32px rgba(0, 0, 0, 0.4);
+  --blob-1: rgba(64, 150, 255, 0.40);
+  --blob-2: rgba(168, 85, 247, 0.34);
+  --blob-3: rgba(34, 211, 238, 0.22);
 }
 
 .login-app.is-dark.is-ultra {
-  --bg-page: #0f2d32;
-  --bg-wave-header: #0a2227;
-  --bg-card: #0c0e12;
-  --wave-fill: #1f4d52;
-  --wave-fill-bottom: #0f2d32;
+  --bg-page: #000;
+  --bg-card: #141414;
+  --color-border: rgba(255, 255, 255, 0.06);
+  --blob-1: rgba(64, 150, 255, 0.22);
+  --blob-2: rgba(168, 85, 247, 0.18);
+  --blob-3: rgba(34, 211, 238, 0.12);
 }
 
-.login-app,
-.login-app :deep(.ant-layout-content) {
-  background: transparent;
+/* Three blurred blobs slowly drift across the page; ::before and
+ * ::after carry two of them, the third lives on .login-content::before
+ * so we can animate it independently. */
+.login-app::before,
+.login-app::after {
+  content: '';
+  position: absolute;
+  width: 70vw;
+  height: 70vw;
+  max-width: 900px;
+  max-height: 900px;
+  border-radius: 50%;
+  filter: blur(90px);
+  pointer-events: none;
+  z-index: 0;
+  will-change: transform;
 }
 
-.login-app {
-  background: var(--bg-page);
+.login-app::before {
+  top: -25vw;
+  left: -20vw;
+  background: radial-gradient(circle, var(--blob-1) 0%, transparent 65%);
+  animation: blob-drift-a 24s ease-in-out infinite alternate;
 }
 
-.login-card {
-  background: var(--bg-card);
-  box-shadow: var(--shadow-card);
+.login-app::after {
+  bottom: -25vw;
+  right: -20vw;
+  background: radial-gradient(circle, var(--blob-2) 0%, transparent 65%);
+  animation: blob-drift-b 30s ease-in-out infinite alternate;
 }
 
-.login-title {
-  color: var(--color-title);
+.login-content::before {
+  content: '';
+  position: absolute;
+  top: 30%;
+  left: 50%;
+  width: 50vw;
+  height: 50vw;
+  max-width: 700px;
+  max-height: 700px;
+  border-radius: 50%;
+  background: radial-gradient(circle, var(--blob-3) 0%, transparent 65%);
+  filter: blur(90px);
+  pointer-events: none;
+  z-index: 0;
+  will-change: transform;
+  animation: blob-drift-c 36s ease-in-out infinite alternate;
 }
 
-.login-settings {
-  display: flex;
-  justify-content: flex-end;
-  margin-bottom: 8px;
+@keyframes blob-drift-a {
+  0%   { transform: translate(0, 0) scale(1); }
+  50%  { transform: translate(18vw, 10vh) scale(1.15); }
+  100% { transform: translate(34vw, 22vh) scale(1.25); }
 }
 
-.settings-popover {
-  min-width: 220px;
+@keyframes blob-drift-b {
+  0%   { transform: translate(0, 0) scale(1); }
+  50%  { transform: translate(-16vw, -10vh) scale(1.12); }
+  100% { transform: translate(-30vw, -22vh) scale(1.2); }
 }
 
-.lang-select {
-  width: 100%;
+@keyframes blob-drift-c {
+  0%   { transform: translate(-50%, -50%) scale(1); }
+  50%  { transform: translate(-20%, -20%) scale(1.1); }
+  100% { transform: translate(-80%, -10%) scale(1.05); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .login-app::before,
+  .login-app::after,
+  .login-content::before {
+    animation: none;
+  }
+}
+
+.login-app :deep(.ant-layout-content) {
+  background: transparent;
 }
 
 .login-content {
   position: relative;
 }
 
-.login-row {
+.login-content > * {
   position: relative;
   z-index: 1;
-  min-height: 100vh;
-  padding: 24px 0;
 }
 
-.login-card {
-  width: clamp(280px, 90vw, 300px);
-  border-radius: 2rem;
-  padding: clamp(2rem, 5vw, 4rem) 1.5rem;
-  transition: background 0.3s, box-shadow 0.3s;
+.login-toolbar {
+  position: fixed;
+  top: 16px;
+  right: 16px;
+  z-index: 10;
 }
 
-.login-loading {
-  text-align: center;
-  padding: 40px 0;
+.toolbar-btn {
+  width: 40px;
+  height: 40px;
 }
 
-.login-title {
-  text-align: center;
-  margin-bottom: 32px;
-  font-size: 2rem;
-  font-weight: 500;
-  min-height: 2.5rem;
+.login-wrapper {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 24px 16px;
 }
 
-.login-title b {
-  display: inline-block;
+.login-loading {
+  text-align: center;
 }
 
-.headline-enter-active,
-.headline-leave-active {
-  transition: opacity 0.4s ease, transform 0.4s ease;
+.login-card {
+  width: 100%;
+  max-width: 400px;
+  background: var(--bg-card);
+  border: 1px solid var(--color-border);
+  border-radius: 12px;
+  padding: 40px 32px 28px;
+  box-shadow: var(--shadow-card);
 }
 
-.headline-enter-from {
-  opacity: 0;
-  transform: translateY(-12px);
+@media (max-width: 480px) {
+  .login-card {
+    padding: 32px 20px 24px;
+  }
 }
 
-.headline-leave-to {
-  opacity: 0;
-  transform: translateY(12px);
+.brand {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 8px;
 }
 
-.waves-header {
-  position: fixed;
-  inset: 0 0 auto 0;
-  width: 100%;
-  z-index: 0;
-  pointer-events: none;
-  background: var(--bg-wave-header);
+.brand-name {
+  font-size: 28px;
+  font-weight: 700;
+  letter-spacing: 1.5px;
+  color: var(--color-text);
 }
 
-.waves-inner-header {
-  height: 50vh;
-  width: 100%;
+.brand-accent {
+  display: block;
+  width: 40px;
+  height: 3px;
+  border-radius: 2px;
+  background: var(--color-accent);
 }
 
-.waves {
-  position: relative;
-  display: block;
-  width: 100%;
-  height: 15vh;
-  min-height: 100px;
-  max-height: 150px;
-  margin-bottom: -8px;
+.welcome {
+  text-align: center;
+  color: var(--color-text);
+  font-size: 32px;
+  font-weight: 700;
+  line-height: 1.2;
+  min-height: 42px;
+  margin: 12px 0 28px;
+  letter-spacing: 0.3px;
 }
 
-.parallax>use {
-  fill: var(--wave-fill);
-  animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
+.welcome b {
+  display: inline-block;
+  font-weight: inherit;
 }
 
-.parallax>use:nth-child(1) {
-  animation-delay: -2s;
-  animation-duration: 4s;
-  opacity: 0.2;
+.headline-enter-active,
+.headline-leave-active {
+  transition: opacity 280ms ease, transform 280ms ease;
+}
+.headline-enter-from {
+  opacity: 0;
+  transform: translateY(6px);
+}
+.headline-leave-to {
+  opacity: 0;
+  transform: translateY(-6px);
 }
 
-.parallax>use:nth-child(2) {
-  animation-delay: -3s;
-  animation-duration: 7s;
-  opacity: 0.4;
+.login-form :deep(.ant-form-item-label > label) {
+  color: var(--color-text);
+  font-weight: 500;
 }
 
-.parallax>use:nth-child(3) {
-  animation-delay: -4s;
-  animation-duration: 10s;
-  opacity: 0.6;
+.submit-row {
+  margin-bottom: 0;
 }
 
-.parallax>use:nth-child(4) {
-  animation-delay: -5s;
-  animation-duration: 13s;
-  fill: var(--wave-fill-bottom);
-  opacity: 1;
+.version {
+  text-align: center;
+  font-size: 12px;
+  color: var(--color-text-subtle);
+  margin-top: 16px;
 }
 
-@keyframes move-forever {
-  0% {
-    transform: translate3d(-90px, 0, 0);
-  }
+.settings-popover {
+  min-width: 220px;
+}
 
-  100% {
-    transform: translate3d(85px, 0, 0);
-  }
+.lang-select {
+  width: 100%;
 }
 </style>

+ 3 - 3
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 =================================================
@@ -172,8 +172,8 @@ async function onToggleEnable(node, next) {
 }
 
 .nodes-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .nodes-page.is-dark.is-ultra {

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

+ 3 - 3
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
@@ -256,8 +256,8 @@ const alertVisible = ref(true);
 }
 
 .settings-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .settings-page.is-dark.is-ultra {

+ 8 - 69
frontend/src/pages/settings/TwoFactorModal.vue

@@ -1,24 +1,13 @@
 <script setup>
-import { nextTick, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import { message } from 'ant-design-vue';
 import * as OTPAuth from 'otpauth';
-import QRious from 'qrious';
 
 import { ClipboardManager } from '@/utils';
 
 const { t } = useI18n();
 
-// Two flavors of this modal:
-//   • type='set' shows a QR code + manual key + a 6-digit verifier
-//     (used when enabling 2FA the first time);
-//   • type='confirm' shows just the 6-digit verifier (used when
-//     toggling 2FA off and when changing the admin user/password).
-//
-// Either way the parent supplies a `confirm(success: boolean)`
-// callback — we run it with `true` only if the entered code matches
-// the live TOTP value, otherwise `false`.
-
 const props = defineProps({
   open: { type: Boolean, default: false },
   title: { type: String, default: '' },
@@ -30,29 +19,10 @@ const props = defineProps({
 const emit = defineEmits(['update:open', 'confirm']);
 
 const enteredCode = ref('');
-const qrCanvas = ref(null);
+const qrValue = ref('');
 
 let totp = null;
 
-// Byte-mode capacities (level L) for QR versions 1..40 — used to pick
-// the matrix width up front so the canvas size is an exact multiple of
-// pixelSize. Without this, QRious renders at floor(size/matrix) and
-// centers, leaving a white margin around the QR.
-const QR_L_BYTE_CAPACITY = [
-  17, 32, 53, 78, 106, 134, 154, 192, 230, 271,
-  321, 367, 425, 458, 520, 586, 644, 718, 792, 858,
-  929, 1003, 1091, 1171, 1273, 1367, 1465, 1528, 1628, 1732,
-  1840, 1952, 2068, 2188, 2303, 2431, 2563, 2699, 2809, 2953,
-];
-
-function pickQrMatrixWidth(value) {
-  const byteLen = new TextEncoder().encode(value).length;
-  for (let i = 0; i < QR_L_BYTE_CAPACITY.length; i++) {
-    if (byteLen <= QR_L_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1);
-  }
-  return 17 + 4 * 40;
-}
-
 function buildTotp() {
   totp = new OTPAuth.TOTP({
     issuer: '3x-ui',
@@ -62,25 +32,7 @@ function buildTotp() {
     period: 30,
     secret: props.token,
   });
-}
-
-async function paintQr() {
-  await nextTick();
-  if (!qrCanvas.value || !totp) return;
-  const value = totp.toString();
-  const matrixWidth = pickQrMatrixWidth(value);
-  const pixelSize = Math.max(1, Math.floor(200 / matrixWidth));
-  const exactSize = matrixWidth * pixelSize;
-  new QRious({
-    element: qrCanvas.value,
-    size: exactSize,
-    value,
-    background: 'white',
-    backgroundAlpha: 1,
-    foreground: 'black',
-    padding: 0,
-    level: 'L',
-  });
+  qrValue.value = totp.toString();
 }
 
 watch(() => props.open, (next) => {
@@ -88,7 +40,6 @@ watch(() => props.open, (next) => {
   enteredCode.value = '';
   if (props.token) {
     buildTotp();
-    if (props.type === 'set') paintQr();
   }
 });
 
@@ -124,9 +75,8 @@ async function copyToken() {
       <a-divider />
       <p>{{ t('pages.settings.security.twoFactorModalFirstStep') }}</p>
       <div class="qr-wrap">
-        <div class="qr-bg">
-          <canvas ref="qrCanvas" class="qr-cv" @click="copyToken" />
-        </div>
+        <a-qrcode class="qr-code" :value="qrValue" :size="180" type="svg" :bordered="false"
+          error-level="L" :title="t('copy')" @click="copyToken" />
         <span class="qr-token">{{ token }}</span>
       </div>
       <a-divider />
@@ -154,24 +104,13 @@ async function copyToken() {
   gap: 12px;
 }
 
-.qr-bg {
-  width: 180px;
-  height: 180px;
+.qr-code {
+  cursor: pointer;
+  padding: 0 !important;
   background: #fff;
-  padding: 4px;
   border-radius: 6px;
 }
 
-.qr-cv {
-  cursor: pointer;
-  width: 100% !important;
-  height: 100% !important;
-  /* Drawing buffer is matrix-snapped (smaller than display size); scale
-   * up crisply so the QR fills the box without blurring. */
-  image-rendering: pixelated;
-  image-rendering: crisp-edges;
-}
-
 .qr-token {
   font-size: 12px;
   font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

+ 12 - 34
frontend/src/pages/sub/SubPage.vue

@@ -9,7 +9,6 @@ import {
   CopyOutlined,
 } from '@ant-design/icons-vue';
 import { message } from 'ant-design-vue';
-import QRious from 'qrious';
 
 import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
 import {
@@ -71,32 +70,7 @@ function onLangChange(next) {
   LanguageManager.setLanguage(next);
 }
 
-// QR code rendering ===========================================
-// Each ref points at a canvas element we paint after mount; QRious
-// sizes itself from the element's `size` attribute.
-const subQr = ref(null);
-const subJsonQr = ref(null);
-const subClashQr = ref(null);
-
-function paintQr(canvas, value) {
-  if (!canvas || !value) return;
-  new QRious({
-    element: canvas,
-    size: 220,
-    value,
-    background: 'white',
-    backgroundAlpha: 1,
-    foreground: 'black',
-    padding: 4,
-    level: 'M',
-  });
-}
-
-onMounted(() => {
-  paintQr(subQr.value, subUrl);
-  paintQr(subJsonQr.value, subJsonUrl);
-  paintQr(subClashQr.value, subClashUrl);
-});
+const QR_SIZE = 240;
 
 // Actions =====================================================
 async function copy(value) {
@@ -184,7 +158,8 @@ const themeClass = computed(() => ({
                 <a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
-                    <canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
+                    <a-qrcode class="qr-code" :value="subUrl" :size="QR_SIZE" type="svg" :bordered="false"
+                      :title="t('copy')" @click="copy(subUrl)" />
                   </div>
                 </a-col>
                 <a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@@ -192,13 +167,15 @@ const themeClass = computed(() => ({
                     <a-tag color="purple" class="qr-tag">
                       {{ t('pages.settings.subSettings') }} JSON
                     </a-tag>
-                    <canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
+                    <a-qrcode class="qr-code" :value="subJsonUrl" :size="QR_SIZE" type="svg" :bordered="false"
+                      :title="t('copy')" @click="copy(subJsonUrl)" />
                   </div>
                 </a-col>
                 <a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
                   <div class="qr-box">
                     <a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
-                    <canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
+                    <a-qrcode class="qr-code" :value="subClashUrl" :size="QR_SIZE" type="svg" :bordered="false"
+                      :title="t('copy')" @click="copy(subClashUrl)" />
                   </div>
                 </a-col>
               </a-row>
@@ -299,8 +276,8 @@ const themeClass = computed(() => ({
 }
 
 .subscription-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .subscription-page.is-dark.is-ultra {
@@ -336,7 +313,7 @@ const themeClass = computed(() => ({
   flex-direction: column;
   align-items: center;
   gap: 4px;
-  width: 220px;
+  width: 240px;
 }
 
 .qr-tag {
@@ -345,8 +322,9 @@ const themeClass = computed(() => ({
   margin: 0;
 }
 
-.qr-canvas {
+.qr-code {
   cursor: pointer;
+  padding: 0 !important;
   background: #fff;
   border-radius: 4px;
 }

+ 124 - 2
frontend/src/pages/xray/BalancersTab.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { computed, ref } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useI18n } from 'vue-i18n';
 import {
   PlusOutlined,
@@ -30,6 +30,30 @@ const STRATEGY_LABELS = {
   leastPing: 'Least ping',
 };
 
+// Observatory defaults — values that the legacy panel seeded when a
+// leastPing balancer first appeared. ProbeURL / interval follow Xray's
+// own docs (https://xtls.github.io/config/observatory.html).
+const DEFAULT_OBSERVATORY = Object.freeze({
+  subjectSelector: [],
+  probeURL: 'https://www.google.com/generate_204',
+  probeInterval: '1m',
+  enableConcurrency: true,
+});
+
+// BurstObservatory defaults — seeded when a leastLoad balancer is
+// configured. Hicloud's generate_204 is the same connectivity probe
+// the legacy panel used (https://xtls.github.io/config/burstobservatory.html).
+const DEFAULT_BURST_OBSERVATORY = Object.freeze({
+  subjectSelector: [],
+  pingConfig: {
+    destination: 'https://www.google.com/generate_204',
+    interval: '1m',
+    connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
+    timeout: '5s',
+    sampling: 2,
+  },
+});
+
 const rows = computed(() => {
   const list = props.templateSettings?.routing?.balancers || [];
   return list.map((b, idx) => ({
@@ -83,6 +107,41 @@ function ensureBalancersArray() {
   return props.templateSettings.routing.balancers;
 }
 
+// Keep observatory / burstObservatory in sync with the configured
+// balancers. leastPing balancers feed Observatory's subjectSelector;
+// leastLoad balancers feed BurstObservatory's. When the matching
+// strategy disappears we drop the observatory entirely so the rendered
+// xray config stays minimal.
+function collectSelectors(list) {
+  const out = new Set();
+  list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
+  return [...out];
+}
+
+function syncObservatories() {
+  const t = props.templateSettings;
+  if (!t) return;
+  const balancers = t.routing?.balancers || [];
+
+  const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
+  if (leastPings.length > 0) {
+    if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
+    t.observatory.subjectSelector = collectSelectors(leastPings);
+  } else {
+    delete t.observatory;
+  }
+
+  const leastLoads = balancers.filter((b) => b.strategy?.type === 'leastLoad');
+  if (leastLoads.length > 0) {
+    if (!t.burstObservatory) {
+      t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
+    }
+    t.burstObservatory.subjectSelector = collectSelectors(leastLoads);
+  } else {
+    delete t.burstObservatory;
+  }
+}
+
 function buildWireBalancer(form) {
   const out = {
     tag: form.tag,
@@ -115,6 +174,7 @@ function onConfirm(form) {
       }
     }
   }
+  syncObservatories();
   modalOpen.value = false;
 }
 
@@ -128,7 +188,10 @@ function confirmDelete(idx) {
     // 4 leaves the modal open if onOk returns a truthy non-thenable
     // (it expects a Promise to await), and splice() returns the array
     // of removed items.
-    onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); },
+    onOk: () => {
+      props.templateSettings.routing.balancers.splice(idx, 1);
+      syncObservatories();
+    },
   });
 }
 
@@ -139,6 +202,49 @@ const columns = computed(() => [
   { title: 'Selector', key: 'selector', align: 'center' },
   { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
 ]);
+
+// === Observatory / BurstObservatory inline editor ====================
+// The legacy panel surfaced both top-level observatory blocks here as a
+// raw JSON editor so admins could tune probeURL / interval / sampling
+// without having to drop into the full xray template tab. We keep that
+// affordance but only render it when the matching observatory exists —
+// which is itself driven by syncObservatories() above.
+const hasObservatory = computed(() => !!props.templateSettings?.observatory);
+const hasBurstObservatory = computed(() => !!props.templateSettings?.burstObservatory);
+const showObsEditor = computed(() => hasObservatory.value || hasBurstObservatory.value);
+
+const obsView = ref('observatory');
+
+// Keep the radio selection valid as observatories appear/disappear —
+// e.g. deleting the last leastPing balancer should flip the editor to
+// the burstObservatory pane instead of leaving it pointing at the
+// (now-removed) observatory key.
+watch(showObsEditor, () => {
+  if (obsView.value === 'observatory' && !hasObservatory.value && hasBurstObservatory.value) {
+    obsView.value = 'burstObservatory';
+  } else if (obsView.value === 'burstObservatory' && !hasBurstObservatory.value && hasObservatory.value) {
+    obsView.value = 'observatory';
+  }
+}, { immediate: true });
+
+const obsText = computed({
+  get: () => {
+    const t = props.templateSettings;
+    if (!t) return '';
+    const src = obsView.value === 'observatory' ? t.observatory : t.burstObservatory;
+    return src ? JSON.stringify(src, null, 2) : '';
+  },
+  set: (next) => {
+    let parsed;
+    try { parsed = JSON.parse(next); } catch (_e) { return; }
+    if (!props.templateSettings) return;
+    if (obsView.value === 'observatory') {
+      props.templateSettings.observatory = parsed;
+    } else {
+      props.templateSettings.burstObservatory = parsed;
+    }
+  },
+});
 </script>
 
 <template>
@@ -192,6 +298,16 @@ const columns = computed(() => [
           </template>
         </template>
       </a-table>
+
+      <template v-if="showObsEditor">
+        <a-divider :style="{ margin: '8px 0' }" />
+        <a-radio-group v-model:value="obsView" button-style="solid" size="small">
+          <a-radio-button v-if="hasObservatory" value="observatory">Observatory</a-radio-button>
+          <a-radio-button v-if="hasBurstObservatory" value="burstObservatory">Burst Observatory</a-radio-button>
+        </a-radio-group>
+        <a-textarea v-model:value="obsText" :auto-size="{ minRows: 8, maxRows: 24 }" spellcheck="false"
+          class="json-editor" />
+      </template>
     </template>
 
     <BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
@@ -213,4 +329,10 @@ const columns = computed(() => [
 .danger {
   color: #ff4d4f;
 }
+
+.json-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  margin-top: 8px;
+}
 </style>

+ 3 - 3
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.
@@ -339,8 +339,8 @@ function confirmRestart() {
 }
 
 .xray-page.is-dark {
-  --bg-page: #0a1222;
-  --bg-card: #151f31;
+  --bg-page: #1e1e1e;
+  --bg-card: #252526;
 }
 
 .xray-page.is-dark.is-ultra {

+ 2 - 3
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>`);
     },
   };
@@ -163,7 +163,6 @@ export default defineConfig({
             || id.includes('/node_modules/@vue/')
           ) return 'vendor-vue';
           if (id.includes('dayjs')) return 'vendor-dayjs';
-          if (id.includes('qrious')) return 'vendor-qrious';
           if (id.includes('axios')) return 'vendor-axios';
           if (
             id.includes('vue3-persian-datetime-picker')

+ 52 - 9
install.sh

@@ -528,21 +528,24 @@ ssl_cert_issue() {
 # Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
 prompt_and_setup_ssl() {
     local panel_port="$1"
-    local web_base_path="$2" # expected without leading slash
+    local web_base_path="$2"
     local server_ip="$3"
 
     local ssl_choice=""
+    SSL_SCHEME="https"
 
     echo -e "${yellow}Choose SSL certificate setup method:${plain}"
     echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
     echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
     echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
+    echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
     echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
+    echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
     read -rp "Choose an option (default 2 for IP): " ssl_choice
     ssl_choice="${ssl_choice// /}" # Trim whitespace
 
-    # Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
-    if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
+    # Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
+    if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
         ssl_choice="2"
     fi
 
@@ -653,6 +656,41 @@ prompt_and_setup_ssl() {
 
             systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
             ;;
+        4)
+            echo ""
+            echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
+            echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
+            echo -e "${yellow}Only safe when:${plain}"
+            echo -e "${yellow}  • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
+            echo -e "${yellow}  • You access the panel exclusively via SSH tunnel${plain}"
+            echo ""
+
+            SSL_SCHEME="http"
+            SSL_HOST="${server_ip}"
+
+            local bind_local=""
+            read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
+            if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
+                ${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
+                SSL_HOST="127.0.0.1"
+                echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
+                echo ""
+                echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
+                echo -e "  Standard SSH command:"
+                echo -e "  ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
+                echo -e "  If using an SSH key:"
+                echo -e "  ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
+                echo -e "  Then open in your browser:"
+                echo -e "  ${yellow}http://localhost:2222/${web_base_path}${plain}"
+                echo ""
+                echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
+            else
+                echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
+            fi
+
+            systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
+            echo -e "${green}✓ SSL setup skipped.${plain}"
+            ;;
         *)
             echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
             SSL_HOST="${server_ip}"
@@ -716,9 +754,10 @@ config_after_install() {
 
             echo ""
             echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${green}     SSL Certificate Setup (MANDATORY)     ${plain}"
+            echo -e "${green}     SSL Certificate Setup (RECOMMENDED)   ${plain}"
             echo -e "${green}═══════════════════════════════════════════${plain}"
-            echo -e "${yellow}For security, SSL certificate is required for all panels.${plain}"
+            echo -e "${yellow}SSL is strongly recommended. Skip only if a reverse proxy${plain}"
+            echo -e "${yellow}or SSH tunnel handles TLS for you.${plain}"
             echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
             echo ""
 
@@ -733,10 +772,14 @@ config_after_install() {
             echo -e "${green}Password:    ${config_password}${plain}"
             echo -e "${green}Port:        ${config_port}${plain}"
             echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
-            echo -e "${green}Access URL:  https://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
+            echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
             echo -e "${green}═══════════════════════════════════════════${plain}"
             echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
-            echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
+            if [[ "$SSL_SCHEME" == "https" ]]; then
+                echo -e "${yellow}⚠ SSL Certificate: Enabled and configured${plain}"
+            else
+                echo -e "${yellow}⚠ SSL Certificate: Skipped — panel is HTTP-only. Use a reverse proxy or SSH tunnel.${plain}"
+            fi
         else
             local config_webBasePath=$(gen_random_string 18)
             echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
@@ -752,7 +795,7 @@ config_after_install() {
                 echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
                 echo ""
                 prompt_and_setup_ssl "${existing_port}" "${config_webBasePath}" "${server_ip}"
-                echo -e "${green}Access URL:  https://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
+                echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${config_webBasePath}${plain}"
             else
                 # If a cert already exists, just show the access URL
                 echo -e "${green}Access URL: https://${server_ip}:${existing_port}/${config_webBasePath}${plain}"
@@ -785,7 +828,7 @@ config_after_install() {
             echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
             echo ""
             prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
-            echo -e "${green}Access URL:  https://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
+            echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${existing_port}/${existing_webBasePath}${plain}"
         else
             echo -e "${green}SSL certificate already configured. No action needed.${plain}"
         fi

+ 14 - 7
sub/subController.go

@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"os"
 	"strconv"
 	"strings"
 
@@ -150,15 +151,21 @@ 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) {
-	dist := webpkg.EmbeddedDist()
-	body, err := dist.ReadFile("dist/subpage.html")
-	if err != nil {
-		c.String(http.StatusInternalServerError, "missing embedded subpage")
-		return
+	var body []byte
+	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
+		body = diskBody
+	} else {
+		dist := webpkg.EmbeddedDist()
+		readBody, err := dist.ReadFile("dist/subpage.html")
+		if err != nil {
+			c.String(http.StatusInternalServerError, "missing embedded subpage")
+			return
+		}
+		body = readBody
 	}
 
 	// Vite emits absolute asset URLs (`/assets/...`); when the panel is
@@ -219,7 +226,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)

+ 0 - 4
web/controller/server.go

@@ -343,10 +343,6 @@ func (a *ServerController) importDB(c *gin.Context) {
 		return
 	}
 	defer file.Close()
-	// Always restart Xray before return
-	defer a.serverService.RestartXrayService()
-	// lastGetStatusTime removed; no longer needed
-	// Import it
 	err = a.serverService.ImportDB(file)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)

+ 9 - 3
web/service/server.go

@@ -976,12 +976,18 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 		return common.NewErrorf("Invalid or corrupt db file: %v", err)
 	}
 
-	// Stop Xray (ignore error but log)
+	xrayStopped := true
+	defer func() {
+		if xrayStopped {
+			if errR := s.RestartXrayService(); errR != nil {
+				logger.Warningf("Failed to restart Xray after DB import error: %v", errR)
+			}
+		}
+	}()
 	if errStop := s.StopXrayService(); errStop != nil {
 		logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
 	}
 
-	// Close existing DB to release file locks (especially on Windows)
 	if errClose := database.CloseDB(); errClose != nil {
 		logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
 	}
@@ -1029,7 +1035,7 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 
 	s.inboundService.MigrateDB()
 
-	// Start Xray
+	xrayStopped = false
 	if err = s.RestartXrayService(); err != nil {
 		return common.NewErrorf("Imported DB but failed to start Xray: %v", err)
 	}