3 次代碼提交 12c10dbd98 ... b885a1f8a6

作者 SHA1 備註 提交日期
  MHSanaei b885a1f8a6 fix(index): improve mobile dashboard layout 17 小時之前
  MHSanaei 439f4cf1e8 Build frontend for CodeQL; remove release analyze job 18 小時之前
  Sanaei bc00d37ad8 Vue3 migration (#4198) 18 小時之前
共有 100 個文件被更改,包括 22741 次插入238 次删除
  1. 6 3
      .github/copilot-instructions.md
  2. 18 0
      .github/workflows/codeql.yml
  3. 34 39
      .github/workflows/release.yml
  4. 18 0
      Dockerfile
  5. 1 0
      database/db.go
  6. 37 0
      database/model/model.go
  7. 3 0
      frontend/.gitignore
  8. 75 0
      frontend/README.md
  9. 61 0
      frontend/eslint.config.js
  10. 13 0
      frontend/inbounds.html
  11. 13 0
      frontend/index.html
  12. 14 0
      frontend/login.html
  13. 13 0
      frontend/nodes.html
  14. 2785 0
      frontend/package-lock.json
  15. 38 0
      frontend/package.json
  16. 13 0
      frontend/settings.html
  17. 117 0
      frontend/src/api/axios-init.js
  18. 9 5
      frontend/src/api/websocket.js
  19. 186 0
      frontend/src/components/AppSidebar.vue
  20. 27 0
      frontend/src/components/CustomStatistic.vue
  21. 384 0
      frontend/src/components/DateTimePicker.vue
  22. 542 0
      frontend/src/components/FinalMaskForm.vue
  23. 25 0
      frontend/src/components/InfinityIcon.vue
  24. 70 0
      frontend/src/components/PromptModal.vue
  25. 31 0
      frontend/src/components/SettingListItem.vue
  26. 347 0
      frontend/src/components/Sparkline.vue
  27. 300 0
      frontend/src/components/TableSortable.vue
  28. 67 0
      frontend/src/components/TextModal.vue
  29. 46 0
      frontend/src/components/ThemeSwitch.vue
  30. 25 0
      frontend/src/components/ThemeSwitchLogin.vue
  31. 45 0
      frontend/src/composables/useDatepicker.js
  32. 26 0
      frontend/src/composables/useMediaQuery.js
  33. 42 0
      frontend/src/composables/useNodeList.js
  34. 43 0
      frontend/src/composables/useStatus.js
  35. 128 0
      frontend/src/composables/useTheme.js
  36. 48 0
      frontend/src/composables/useWebSocket.js
  37. 17 0
      frontend/src/entries/inbounds.js
  38. 19 0
      frontend/src/entries/index.js
  39. 21 0
      frontend/src/entries/login.js
  40. 17 0
      frontend/src/entries/nodes.js
  41. 19 0
      frontend/src/entries/settings.js
  42. 18 0
      frontend/src/entries/subpage.js
  43. 17 0
      frontend/src/entries/xray.js
  44. 93 0
      frontend/src/i18n/index.js
  45. 11 4
      frontend/src/models/dbinbound.js
  46. 58 43
      frontend/src/models/inbound.js
  47. 39 37
      frontend/src/models/outbound.js
  48. 24 24
      frontend/src/models/reality-targets.js
  49. 8 1
      frontend/src/models/setting.js
  50. 78 0
      frontend/src/models/status.js
  51. 273 0
      frontend/src/pages/inbounds/ClientBulkModal.vue
  52. 394 0
      frontend/src/pages/inbounds/ClientFormModal.vue
  53. 610 0
      frontend/src/pages/inbounds/ClientRowTable.vue
  54. 1790 0
      frontend/src/pages/inbounds/InboundFormModal.vue
  55. 1012 0
      frontend/src/pages/inbounds/InboundInfoModal.vue
  56. 621 0
      frontend/src/pages/inbounds/InboundList.vue
  57. 692 0
      frontend/src/pages/inbounds/InboundsPage.vue
  58. 67 0
      frontend/src/pages/inbounds/QrCodeModal.vue
  59. 158 0
      frontend/src/pages/inbounds/QrPanel.vue
  60. 323 0
      frontend/src/pages/inbounds/useInbounds.js
  61. 101 0
      frontend/src/pages/index/BackupModal.vue
  62. 106 0
      frontend/src/pages/index/CustomGeoFormModal.vue
  63. 311 0
      frontend/src/pages/index/CustomGeoSection.vue
  64. 420 0
      frontend/src/pages/index/IndexPage.vue
  65. 165 0
      frontend/src/pages/index/LogModal.vue
  66. 112 0
      frontend/src/pages/index/PanelUpdateModal.vue
  67. 96 0
      frontend/src/pages/index/StatusCard.vue
  68. 163 0
      frontend/src/pages/index/SystemHistoryModal.vue
  69. 147 0
      frontend/src/pages/index/VersionModal.vue
  70. 182 0
      frontend/src/pages/index/XrayLogModal.vue
  71. 144 0
      frontend/src/pages/index/XrayStatusCard.vue
  72. 350 0
      frontend/src/pages/login/LoginPage.vue
  73. 223 0
      frontend/src/pages/nodes/NodeFormModal.vue
  74. 134 0
      frontend/src/pages/nodes/NodeHistoryPanel.vue
  75. 207 0
      frontend/src/pages/nodes/NodeList.vue
  76. 243 0
      frontend/src/pages/nodes/NodesPage.vue
  77. 120 0
      frontend/src/pages/nodes/useNodes.js
  78. 425 0
      frontend/src/pages/settings/GeneralTab.vue
  79. 245 0
      frontend/src/pages/settings/SecurityTab.vue
  80. 309 0
      frontend/src/pages/settings/SettingsPage.vue
  81. 433 0
      frontend/src/pages/settings/SubscriptionFormatsTab.vue
  82. 196 0
      frontend/src/pages/settings/SubscriptionGeneralTab.vue
  83. 106 0
      frontend/src/pages/settings/TelegramTab.vue
  84. 181 0
      frontend/src/pages/settings/TwoFactorModal.vue
  85. 80 0
      frontend/src/pages/settings/useAllSetting.js
  86. 465 0
      frontend/src/pages/sub/SubPage.vue
  87. 133 0
      frontend/src/pages/xray/BalancerFormModal.vue
  88. 210 0
      frontend/src/pages/xray/BalancersTab.vue
  89. 500 0
      frontend/src/pages/xray/BasicsTab.vue
  90. 168 0
      frontend/src/pages/xray/DnsServerModal.vue
  91. 373 0
      frontend/src/pages/xray/DnsTab.vue
  92. 379 0
      frontend/src/pages/xray/NordModal.vue
  93. 1007 0
      frontend/src/pages/xray/OutboundFormModal.vue
  94. 499 0
      frontend/src/pages/xray/OutboundsTab.vue
  95. 405 0
      frontend/src/pages/xray/RoutingTab.vue
  96. 263 0
      frontend/src/pages/xray/RuleFormModal.vue
  97. 347 0
      frontend/src/pages/xray/WarpModal.vue
  98. 431 0
      frontend/src/pages/xray/XrayPage.vue
  99. 246 0
      frontend/src/pages/xray/useXraySetting.js
  100. 87 82
      frontend/src/utils/index.js

+ 6 - 3
.github/copilot-instructions.md

@@ -92,9 +92,12 @@ func (a *InboundController) getInbounds(c *gin.Context) {
 - Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
 
 ### Internationalization
-- Translation files: `web/translation/translate.*.toml`
-- Access via `I18nWeb(c, "pages.login.loginAgain")` in controllers
-- Use `locale.I18nType` enum (Web, Api, etc.)
+- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
+  e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
+  and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
+- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
+  `web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
+- Use `locale.I18nType` enum (Web, Bot).
 
 ## External Dependencies & Integration
 

+ 18 - 0
.github/workflows/codeql.yml

@@ -35,6 +35,24 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v6
 
+      # The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go).
+      # web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with
+      # "pattern all:dist: no matching files found" unless vite emits it first.
+      - name: Setup Node.js
+        if: matrix.language == 'go'
+        uses: actions/setup-node@v6
+        with:
+          node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Build frontend bundle
+        if: matrix.language == 'go'
+        run: |
+          npm ci
+          npm run build
+        working-directory: frontend
+
       - name: Initialize CodeQL
         uses: github/codeql-action/init@v4
         with:

+ 34 - 39
.github/workflows/release.yml

@@ -21,45 +21,7 @@ on:
   pull_request:
 
 jobs:
-  analyze:
-    name: Analyze Go code
-    permissions:
-      contents: read
-    runs-on: ubuntu-latest
-    timeout-minutes: 20
-    steps:
-      - name: Checkout repository
-        uses: actions/checkout@v6
-
-      - name: Set up Go
-        uses: actions/setup-go@v6
-        with:
-          go-version-file: go.mod
-          cache: true
-
-      - name: Check formatting
-        run: |
-          unformatted=$(gofmt -l .)
-          if [ -n "$unformatted" ]; then
-            echo "These files are not gofmt-formatted:"
-            echo "$unformatted"
-            exit 1
-          fi
-
-      - name: Run go vet
-        run: go vet ./...
-
-      - name: Run staticcheck
-        uses: dominikh/staticcheck-action@v1
-        with:
-          version: "latest"
-          install-go: false
-
-      - name: Run tests
-        run: go test -race -shuffle=on ./...
-
   build:
-    needs: analyze
     permissions:
       contents: write
     strategy:
@@ -83,6 +45,23 @@ jobs:
           go-version-file: go.mod
           check-latest: true
 
+      # Frontend dist must be built BEFORE go build — Go's //go:embed
+      # all:dist directive in web/web.go requires web/dist/ to exist
+      # at compile time. web/dist/ is .gitignored, so on a fresh CI
+      # checkout it doesn't exist until vite emits it.
+      - name: Setup Node.js
+        uses: actions/setup-node@v6
+        with:
+          node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Build frontend bundle
+        run: |
+          npm ci
+          npm run build
+        working-directory: frontend
+
       - name: Build 3X-UI
         run: |
           export CGO_ENABLED=1
@@ -191,7 +170,6 @@ jobs:
   # =================================
   build-windows:
     name: Build for Windows
-    needs: analyze
     permissions:
       contents: write
     strategy:
@@ -209,6 +187,23 @@ jobs:
           go-version-file: go.mod
           check-latest: true
 
+      # Frontend dist must be built BEFORE go build — see comment on the
+      # Linux job above. This step is identical except npm runs on the
+      # Windows runner here.
+      - name: Setup Node.js
+        uses: actions/setup-node@v6
+        with:
+          node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Build frontend bundle
+        shell: pwsh
+        run: |
+          npm ci
+          npm run build
+        working-directory: frontend
+
       - name: Install MSYS2
         uses: msys2/setup-msys2@v2
         with:

+ 18 - 0
Dockerfile

@@ -1,3 +1,20 @@
+# ========================================================
+# Stage: Frontend (Vite)
+# ========================================================
+# web/dist/ is .gitignored and embedded into the Go binary via
+# //go:embed all:dist in web/web.go, so the SPA bundle MUST be built
+# before the Go compile step. We build it in its own stage so the
+# Go builder image doesn't need Node installed.
+FROM node:22-alpine AS frontend
+WORKDIR /src/frontend
+COPY frontend/package.json frontend/package-lock.json ./
+RUN npm ci
+COPY frontend/ ./
+RUN npm run build
+# Vite outDir is set to ../web/dist (see frontend/vite.config.js), so
+# the bundle lands at /src/web/dist — that's what we copy into the
+# next stage.
+
 # ========================================================
 # Stage: Builder
 # ========================================================
@@ -12,6 +29,7 @@ RUN apk --no-cache --update add \
   unzip
 
 COPY . .
+COPY --from=frontend /src/web/dist ./web/dist
 
 ENV CGO_ENABLED=1
 ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"

+ 1 - 0
database/db.go

@@ -39,6 +39,7 @@ func initModels() error {
 		&xray.ClientTraffic{},
 		&model.HistoryOfSeeders{},
 		&model.CustomGeoResource{},
+		&model.Node{},
 	}
 	for _, model := range models {
 		if err := db.AutoMigrate(model); err != nil {

+ 37 - 0
database/model/model.go

@@ -66,6 +66,12 @@ type Inbound struct {
 	StreamSettings string   `json:"streamSettings" form:"streamSettings"`
 	Tag            string   `json:"tag" form:"tag" gorm:"unique"`
 	Sniffing       string   `json:"sniffing" form:"sniffing"`
+
+	// NodeID points at the remote panel (Node) where this inbound's xray
+	// actually runs. NULL means the inbound runs on the local xray (the
+	// pre-multi-node behaviour). Existing rows migrate to NULL with no
+	// backfill.
+	NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
 }
 
 // OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -117,6 +123,37 @@ type Setting struct {
 	Value string `json:"value" form:"value"`
 }
 
+// Node represents a remote 3x-ui panel registered with the central panel.
+// The central panel polls each node's existing /panel/api/server/status
+// endpoint over HTTP using the per-node ApiToken to populate the runtime
+// status fields below.
+type Node struct {
+	Id       int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	Name     string `json:"name" gorm:"uniqueIndex"`
+	Remark   string `json:"remark"`
+	Scheme   string `json:"scheme"`  // "https" | "http"
+	Address  string `json:"address"` // host or IP
+	Port     int    `json:"port"`
+	BasePath string `json:"basePath"` // "/" or "/myprefix/"
+	ApiToken string `json:"apiToken"` // plaintext, matches existing tg/ldap pattern
+	Enable   bool   `json:"enable" gorm:"default:true"`
+
+	// Heartbeat-updated fields. UpdatedAt advances on every probe even when
+	// the row is otherwise unchanged so the UI's "last seen" tooltip is
+	// truthful without us having to read LastHeartbeat separately.
+	Status        string  `json:"status" gorm:"default:unknown"` // online|offline|unknown
+	LastHeartbeat int64   `json:"lastHeartbeat"`                 // unix seconds, 0 = never
+	LatencyMs     int     `json:"latencyMs"`
+	XrayVersion   string  `json:"xrayVersion"`
+	CpuPct        float64 `json:"cpuPct"`
+	MemPct        float64 `json:"memPct"`
+	UptimeSecs    uint64  `json:"uptimeSecs"`
+	LastError     string  `json:"lastError"`
+
+	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
+	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
+}
+
 type CustomGeoResource struct {
 	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`

+ 3 - 0
frontend/.gitignore

@@ -0,0 +1,3 @@
+node_modules/
+.vite/
+*.log

+ 75 - 0
frontend/README.md

@@ -0,0 +1,75 @@
+# 3x-ui frontend
+
+Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
+panel route — built into `../web/dist/` and embedded into the Go binary
+via `embed.FS`.
+
+## Dev
+
+```sh
+npm install
+npm run dev
+```
+
+Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
+proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
+first (`go run main.go`) and then Vite.
+
+The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
+`/panel/xray` to the matching Vite-served HTML in dev mode (see
+`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
+production-style links work without round-tripping through Go.
+
+## Production build
+
+```sh
+npm run build
+```
+
+Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
+`assets/`). The Go binary embeds this directory at compile time and
+`web/controller/dist.go` serves the per-page HTML.
+
+## Lint
+
+```sh
+npm run lint
+```
+
+ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
+plus a few rule overrides for the project's formatting style.
+
+## Layout
+
+```
+frontend/
+├── *.html                 # Vite entry HTML, one per panel route
+├── eslint.config.js
+├── vite.config.js
+└── src/
+    ├── entries/           # Per-page bootstrap (createApp + mount)
+    ├── pages/             # One folder per route, each with the page
+    │   ├── index/         # component + helpers + sub-components
+    │   ├── login/
+    │   ├── inbounds/
+    │   ├── xray/
+    │   ├── settings/
+    │   └── sub/
+    ├── components/        # Cross-page Vue components
+    ├── composables/       # Reusable reactive logic (useTheme, …)
+    ├── api/               # Axios setup, CSRF interceptor
+    ├── i18n/              # vue-i18n init (locales live in web/translation/)
+    ├── models/            # Inbound, Outbound, Status, … domain classes
+    └── utils/             # HttpUtil, ObjectUtil, LanguageManager, …
+```
+
+## Adding a new page
+
+1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
+2. Add `src/entries/<page>.js` that imports the page component and
+   mounts it.
+3. Add the page component under `src/pages/<page>/`.
+4. Register the entry in `rollupOptions.input` in `vite.config.js`.
+5. If the page is reachable from the sidebar at `/panel/<route>`, add
+   it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
+6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.

+ 61 - 0
frontend/eslint.config.js

@@ -0,0 +1,61 @@
+import js from '@eslint/js';
+import vue from 'eslint-plugin-vue';
+import vueParser from 'vue-eslint-parser';
+import globals from 'globals';
+
+export default [
+  { ignores: ['node_modules/**', '../web/dist/**'] },
+  js.configs.recommended,
+  ...vue.configs['flat/recommended'],
+  {
+    files: ['**/*.{js,vue}'],
+    languageOptions: {
+      ecmaVersion: 2022,
+      sourceType: 'module',
+      parser: vueParser,
+      parserOptions: {
+        ecmaFeatures: { jsx: false },
+      },
+      globals: {
+        ...globals.browser,
+        ...globals.node,
+        // Legacy script tags inject a couple of helpers on window before
+        // the SPA boots; declared here so no-undef stops flagging them.
+        getRandomRealityTarget: 'readonly',
+      },
+    },
+    rules: {
+      'no-unused-vars': ['warn', {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_',
+        caughtErrorsIgnorePattern: '^_',
+      }],
+      'no-empty': ['error', { allowEmptyCatch: true }],
+      'no-case-declarations': 'off',
+
+      // Stylistic rules from vue/recommended that don't match the
+      // existing codebase formatting. Disable rather than churn the
+      // whole tree to satisfy them.
+      'vue/multi-word-component-names': 'off',
+      'vue/no-v-html': 'off',
+      'vue/html-self-closing': 'off',
+      'vue/max-attributes-per-line': 'off',
+      'vue/singleline-html-element-content-newline': 'off',
+      'vue/multiline-html-element-content-newline': 'off',
+      'vue/html-indent': 'off',
+      'vue/html-closing-bracket-newline': 'off',
+      'vue/attributes-order': 'off',
+      'vue/first-attribute-linebreak': 'off',
+      'vue/one-component-per-file': 'off',
+      'vue/order-in-components': 'off',
+      'vue/attribute-hyphenation': 'off',
+      'vue/v-on-event-hyphenation': 'off',
+
+      // Pervasive in form components ported from the Vue 2 codebase
+      // (parent passes a reactive object; child mutates it in place).
+      // Properly fixing this means rewiring those components to emit
+      // updates — a meaningful architectural change, separate task.
+      'vue/no-mutating-props': 'off',
+    },
+  },
+];

+ 13 - 0
frontend/inbounds.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>3x-ui · Inbounds</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/inbounds.js"></script>
+  </body>
+</html>

+ 13 - 0
frontend/index.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>3x-ui</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/index.js"></script>
+  </body>
+</html>

+ 14 - 0
frontend/login.html

@@ -0,0 +1,14 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <meta name="robots" content="noindex,nofollow" />
+    <title>3x-ui — Sign in</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/login.js"></script>
+  </body>
+</html>

+ 13 - 0
frontend/nodes.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>3x-ui · Nodes</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/nodes.js"></script>
+  </body>
+</html>

+ 2785 - 0
frontend/package-lock.json

@@ -0,0 +1,2785 @@
+{
+  "name": "3x-ui-frontend",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "3x-ui-frontend",
+      "version": "0.0.0",
+      "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",
+        "vue3-persian-datetime-picker": "^1.2.2"
+      },
+      "devDependencies": {
+        "@eslint/js": "^10.0.1",
+        "@vitejs/plugin-vue": "^6.0.6",
+        "eslint": "^10.3.0",
+        "eslint-plugin-vue": "^10.9.1",
+        "globals": "^17.6.0",
+        "vite": "^8.0.11",
+        "vue-eslint-parser": "^10.4.0"
+      }
+    },
+    "node_modules/@ant-design/colors": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
+      "integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.0"
+      }
+    },
+    "node_modules/@ant-design/icons-svg": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
+      "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="
+    },
+    "node_modules/@ant-design/icons-vue": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
+      "integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-svg": "^4.2.1"
+      },
+      "peerDependencies": {
+        "vue": ">=3.0.3"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.29.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+      "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+      "dependencies": {
+        "@babel/types": "^7.29.0"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.29.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
+      "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.29.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+      "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@emnapi/core": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+      "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "@emnapi/wasi-threads": "1.2.1",
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emnapi/wasi-threads": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+      "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.9.2",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
+      "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+      "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+    },
+    "node_modules/@eslint-community/eslint-utils": {
+      "version": "4.9.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+      "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^3.4.3"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+      }
+    },
+    "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "3.4.3",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+      "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/@eslint-community/regexpp": {
+      "version": "4.12.2",
+      "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+      "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+      "dev": true,
+      "engines": {
+        "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/config-array": {
+      "version": "0.23.5",
+      "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+      "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/object-schema": "^3.0.5",
+        "debug": "^4.3.1",
+        "minimatch": "^10.2.4"
+      },
+      "engines": {
+        "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",
+      "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/core": "^1.2.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      }
+    },
+    "node_modules/@eslint/core": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+      "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.15"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      }
+    },
+    "node_modules/@eslint/js": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+      "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+      "dev": true,
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "eslint": "^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "eslint": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@eslint/object-schema": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+      "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+      "dev": true,
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      }
+    },
+    "node_modules/@eslint/plugin-kit": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz",
+      "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/core": "^1.2.1",
+        "levn": "^0.4.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      }
+    },
+    "node_modules/@humanfs/core": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+      "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+      "dev": true,
+      "dependencies": {
+        "@humanfs/types": "^0.15.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/node": {
+      "version": "0.16.8",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+      "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+      "dev": true,
+      "dependencies": {
+        "@humanfs/core": "^0.19.2",
+        "@humanfs/types": "^0.15.0",
+        "@humanwhocodes/retry": "^0.4.0"
+      },
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanfs/types": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+      "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.18.0"
+      }
+    },
+    "node_modules/@humanwhocodes/module-importer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+      "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.22"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@humanwhocodes/retry": {
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=18.18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/nzakas"
+      }
+    },
+    "node_modules/@intlify/core-base": {
+      "version": "11.4.2",
+      "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
+      "integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
+      "dependencies": {
+        "@intlify/devtools-types": "11.4.2",
+        "@intlify/message-compiler": "11.4.2",
+        "@intlify/shared": "11.4.2"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/kazupon"
+      }
+    },
+    "node_modules/@intlify/devtools-types": {
+      "version": "11.4.2",
+      "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
+      "integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
+      "dependencies": {
+        "@intlify/core-base": "11.4.2",
+        "@intlify/shared": "11.4.2"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/kazupon"
+      }
+    },
+    "node_modules/@intlify/message-compiler": {
+      "version": "11.4.2",
+      "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
+      "integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
+      "dependencies": {
+        "@intlify/shared": "11.4.2",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/kazupon"
+      }
+    },
+    "node_modules/@intlify/shared": {
+      "version": "11.4.2",
+      "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
+      "integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/kazupon"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+    },
+    "node_modules/@napi-rs/wasm-runtime": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
+      "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "@tybys/wasm-util": "^0.10.1"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Brooooooklyn"
+      },
+      "peerDependencies": {
+        "@emnapi/core": "^1.7.1",
+        "@emnapi/runtime": "^1.7.1"
+      }
+    },
+    "node_modules/@noble/hashes": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
+      "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
+      "engines": {
+        "node": ">= 20.19.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/@oxc-project/types": {
+      "version": "0.128.0",
+      "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz",
+      "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/Boshen"
+      }
+    },
+    "node_modules/@rolldown/binding-android-arm64": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz",
+      "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-arm64": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz",
+      "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-darwin-x64": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz",
+      "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-freebsd-x64": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz",
+      "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz",
+      "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-gnu": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz",
+      "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-arm64-musl": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz",
+      "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz",
+      "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-s390x-gnu": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz",
+      "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-gnu": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz",
+      "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-linux-x64-musl": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz",
+      "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-openharmony-arm64": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz",
+      "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-wasm32-wasi": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz",
+      "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==",
+      "cpu": [
+        "wasm32"
+      ],
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "@emnapi/core": "1.10.0",
+        "@emnapi/runtime": "1.10.0",
+        "@napi-rs/wasm-runtime": "^1.1.4"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-arm64-msvc": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz",
+      "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/binding-win32-x64-msvc": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz",
+      "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-rc.13",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+      "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+      "dev": true
+    },
+    "node_modules/@simonwep/pickr": {
+      "version": "1.8.2",
+      "resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
+      "integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
+      "dependencies": {
+        "core-js": "^3.15.1",
+        "nanopop": "^2.1.0"
+      }
+    },
+    "node_modules/@tybys/wasm-util": {
+      "version": "0.10.2",
+      "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
+      "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@types/esrecurse": {
+      "version": "4.3.1",
+      "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+      "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+      "dev": true
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+      "dev": true
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.15",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+      "dev": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "6.0.6",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
+      "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+      "dev": true,
+      "dependencies": {
+        "@rolldown/pluginutils": "1.0.0-rc.13"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+      "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/shared": "3.5.34",
+        "entities": "^7.0.1",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+      "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+      "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+      "dependencies": {
+        "@babel/parser": "^7.29.3",
+        "@vue/compiler-core": "3.5.34",
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.14",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+      "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
+      "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+      "dependencies": {
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+      "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/shared": "3.5.34"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+      "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.34",
+        "@vue/runtime-core": "3.5.34",
+        "@vue/shared": "3.5.34",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+      "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "vue": "3.5.34"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
+      "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA=="
+    },
+    "node_modules/acorn": {
+      "version": "8.16.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+      "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ant-design-vue": {
+      "version": "4.2.6",
+      "resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
+      "integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
+      "dependencies": {
+        "@ant-design/colors": "^6.0.0",
+        "@ant-design/icons-vue": "^7.0.0",
+        "@babel/runtime": "^7.10.5",
+        "@ctrl/tinycolor": "^3.5.0",
+        "@emotion/hash": "^0.9.0",
+        "@emotion/unitless": "^0.8.0",
+        "@simonwep/pickr": "~1.8.0",
+        "array-tree-filter": "^2.1.0",
+        "async-validator": "^4.0.0",
+        "csstype": "^3.1.1",
+        "dayjs": "^1.10.5",
+        "dom-align": "^1.12.1",
+        "dom-scroll-into-view": "^2.0.0",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.15",
+        "resize-observer-polyfill": "^1.5.1",
+        "scroll-into-view-if-needed": "^2.2.25",
+        "shallow-equal": "^1.0.0",
+        "stylis": "^4.1.3",
+        "throttle-debounce": "^5.0.0",
+        "vue-types": "^3.0.0",
+        "warning": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/ant-design-vue"
+      },
+      "peerDependencies": {
+        "vue": ">=3.2.0"
+      }
+    },
+    "node_modules/array-tree-filter": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
+      "integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw=="
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg=="
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
+      "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
+      "dependencies": {
+        "follow-redirects": "^1.16.0",
+        "form-data": "^4.0.5",
+        "proxy-from-env": "^2.1.0"
+      }
+    },
+    "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/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",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/compute-scroll-into-view": {
+      "version": "1.0.20",
+      "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
+      "integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="
+    },
+    "node_modules/core-js": {
+      "version": "3.49.0",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+      "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+      "hasInstallScript": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.6",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+      "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.20",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
+      "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dom-align": {
+      "version": "1.12.4",
+      "resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
+      "integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw=="
+    },
+    "node_modules/dom-scroll-into-view": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
+      "integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w=="
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "10.3.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
+      "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.8.0",
+        "@eslint-community/regexpp": "^4.12.2",
+        "@eslint/config-array": "^0.23.5",
+        "@eslint/config-helpers": "^0.5.5",
+        "@eslint/core": "^1.2.1",
+        "@eslint/plugin-kit": "^0.7.1",
+        "@humanfs/node": "^0.16.6",
+        "@humanwhocodes/module-importer": "^1.0.1",
+        "@humanwhocodes/retry": "^0.4.2",
+        "@types/estree": "^1.0.6",
+        "ajv": "^6.14.0",
+        "cross-spawn": "^7.0.6",
+        "debug": "^4.3.2",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^9.1.2",
+        "eslint-visitor-keys": "^5.0.1",
+        "espree": "^11.2.0",
+        "esquery": "^1.7.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^8.0.0",
+        "find-up": "^5.0.0",
+        "glob-parent": "^6.0.2",
+        "ignore": "^5.2.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "minimatch": "^10.2.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://eslint.org/donate"
+      },
+      "peerDependencies": {
+        "jiti": "*"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-vue": {
+      "version": "10.9.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.1.tgz",
+      "integrity": "sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==",
+      "dev": true,
+      "dependencies": {
+        "@eslint-community/eslint-utils": "^4.4.0",
+        "natural-compare": "^1.4.0",
+        "nth-check": "^2.1.1",
+        "postcss-selector-parser": "^7.1.0",
+        "semver": "^7.6.3",
+        "xml-name-validator": "^4.0.0"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "peerDependencies": {
+        "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
+        "@typescript-eslint/parser": "^7.0.0 || ^8.0.0",
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+        "vue-eslint-parser": "^10.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@stylistic/eslint-plugin": {
+          "optional": true
+        },
+        "@typescript-eslint/parser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "9.1.2",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+      "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/esrecurse": "^4.3.1",
+        "@types/estree": "^1.0.8",
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+      "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+      "dev": true,
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "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",
+      "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.16.0",
+        "acorn-jsx": "^5.3.2",
+        "eslint-visitor-keys": "^5.0.1"
+      },
+      "engines": {
+        "node": "^20.19.0 || ^22.13.0 || >=24"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+      "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+      "dev": true
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+      "dev": true
+    },
+    "node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=16.0.0"
+      }
+    },
+    "node_modules/find-up": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+      "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^6.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.2.9",
+        "keyv": "^4.5.4"
+      },
+      "engines": {
+        "node": ">=16"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.4.2",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+      "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+      "dev": true
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.16.0",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+      "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/globals": {
+      "version": "17.6.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz",
+      "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==",
+      "dev": true,
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+      "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-plain-object": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
+      "integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+      "dev": true
+    },
+    "node_modules/jalaali-js": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
+      "integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A=="
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "node_modules/json-buffer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+      "dev": true
+    },
+    "node_modules/keyv": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "dev": true,
+      "dependencies": {
+        "json-buffer": "3.0.1"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "dev": true,
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-android-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+      "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-arm64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+      "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-darwin-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+      "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-freebsd-x64": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+      "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm-gnueabihf": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+      "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+      "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-arm64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+      "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-gnu": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+      "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-linux-x64-musl": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+      "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-arm64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+      "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+      "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+      "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="
+    },
+    "node_modules/lodash-es": {
+      "version": "4.18.1",
+      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+      "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/moment": {
+      "version": "2.30.1",
+      "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
+      "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/moment-jalaali": {
+      "version": "0.10.4",
+      "resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.10.4.tgz",
+      "integrity": "sha512-/eD0HeyvATznb5iE0G1BHjKRZAFEpJ9ZNUkcHwXhNgt1WJJVVzHD7+uDmqzZWVFLdbGme2gvIXKb3ezDYOXcZA==",
+      "dependencies": {
+        "jalaali-js": "^1.2.7",
+        "moment": "^2.29.4",
+        "moment-timezone": "^0.5.46"
+      }
+    },
+    "node_modules/moment-timezone": {
+      "version": "0.5.48",
+      "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
+      "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
+      "dependencies": {
+        "moment": "^2.29.4"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.12",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+      "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/nanopop": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
+      "integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw=="
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+      "dev": true
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "dev": true,
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/otpauth": {
+      "version": "9.5.1",
+      "resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.1.tgz",
+      "integrity": "sha512-fJmDAHc8wImfqqqOXIlBvT1dEKrZK0Cmb2VEgScpNTolCz0PHh6ExUZGv4sLtOsWNaHCQlD+rRqaPgnoxFoZjQ==",
+      "dependencies": {
+        "@noble/hashes": "2.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/hectorm/otpauth?sponsor=1"
+      }
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+      "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.14",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
+      "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
+      "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+      "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+      "dev": true,
+      "engines": {
+        "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",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
+    "node_modules/rolldown": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz",
+      "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==",
+      "dev": true,
+      "dependencies": {
+        "@oxc-project/types": "=0.128.0",
+        "@rolldown/pluginutils": "1.0.0-rc.18"
+      },
+      "bin": {
+        "rolldown": "bin/cli.mjs"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "optionalDependencies": {
+        "@rolldown/binding-android-arm64": "1.0.0-rc.18",
+        "@rolldown/binding-darwin-arm64": "1.0.0-rc.18",
+        "@rolldown/binding-darwin-x64": "1.0.0-rc.18",
+        "@rolldown/binding-freebsd-x64": "1.0.0-rc.18",
+        "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18",
+        "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18",
+        "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18",
+        "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18",
+        "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18",
+        "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18",
+        "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18",
+        "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18",
+        "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18",
+        "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18",
+        "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18"
+      }
+    },
+    "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+      "version": "1.0.0-rc.18",
+      "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz",
+      "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==",
+      "dev": true
+    },
+    "node_modules/scroll-into-view-if-needed": {
+      "version": "2.2.31",
+      "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
+      "integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
+      "dependencies": {
+        "compute-scroll-into-view": "^1.0.20"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.8.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
+      "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/shallow-equal": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+      "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stylis": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz",
+      "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="
+    },
+    "node_modules/throttle-debounce": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
+      "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.16",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "dev": true,
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "dev": true,
+      "optional": true
+    },
+    "node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true
+    },
+    "node_modules/vite": {
+      "version": "8.0.11",
+      "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz",
+      "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==",
+      "dev": true,
+      "dependencies": {
+        "lightningcss": "^1.32.0",
+        "picomatch": "^4.0.4",
+        "postcss": "^8.5.14",
+        "rolldown": "1.0.0-rc.18",
+        "tinyglobby": "^0.2.16"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^20.19.0 || >=22.12.0",
+        "@vitejs/devtools": "^0.1.18",
+        "esbuild": "^0.27.0 || ^0.28.0",
+        "jiti": ">=1.21.0",
+        "less": "^4.0.0",
+        "sass": "^1.70.0",
+        "sass-embedded": "^1.70.0",
+        "stylus": ">=0.54.8",
+        "sugarss": "^5.0.0",
+        "terser": "^5.16.0",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "@vitejs/devtools": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "jiti": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.34",
+      "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
+      "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.34",
+        "@vue/compiler-sfc": "3.5.34",
+        "@vue/runtime-dom": "3.5.34",
+        "@vue/server-renderer": "3.5.34",
+        "@vue/shared": "3.5.34"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-eslint-parser": {
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
+      "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.4.0",
+        "eslint-scope": "^8.2.0 || ^9.0.0",
+        "eslint-visitor-keys": "^4.2.0 || ^5.0.0",
+        "espree": "^10.3.0 || ^11.0.0",
+        "esquery": "^1.6.0",
+        "semver": "^7.6.3"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
+      }
+    },
+    "node_modules/vue-i18n": {
+      "version": "11.4.2",
+      "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
+      "integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
+      "dependencies": {
+        "@intlify/core-base": "11.4.2",
+        "@intlify/devtools-types": "11.4.2",
+        "@intlify/shared": "11.4.2",
+        "@vue/devtools-api": "^6.5.0"
+      },
+      "engines": {
+        "node": ">= 16"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/kazupon"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vue-types": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
+      "integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
+      "dependencies": {
+        "is-plain-object": "3.0.1"
+      },
+      "engines": {
+        "node": ">=10.15.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.0"
+      }
+    },
+    "node_modules/vue3-persian-datetime-picker": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/vue3-persian-datetime-picker/-/vue3-persian-datetime-picker-1.2.2.tgz",
+      "integrity": "sha512-d7nkj5vgtUvEXZboSdRmP1uwBfXvXgXqdvsOOMQb34jiMZU/aBDrTYWTEe1N+XKF9pvTTJn8Rws9ttJmyhK/hw==",
+      "dependencies": {
+        "moment-jalaali": "^0.9.4"
+      }
+    },
+    "node_modules/warning": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+      "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+      "dependencies": {
+        "loose-envify": "^1.0.0"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+      "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  }
+}

+ 38 - 0
frontend/package.json

@@ -0,0 +1,38 @@
+{
+  "name": "3x-ui-frontend",
+  "private": true,
+  "version": "0.0.1",
+  "type": "module",
+  "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "lint": "eslint src"
+  },
+  "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",
+    "vue3-persian-datetime-picker": "^1.2.2"
+  },
+  "devDependencies": {
+    "@eslint/js": "^10.0.1",
+    "@vitejs/plugin-vue": "^6.0.6",
+    "eslint": "^10.3.0",
+    "eslint-plugin-vue": "^10.9.1",
+    "globals": "^17.6.0",
+    "vite": "^8.0.11",
+    "vue-eslint-parser": "^10.4.0"
+  },
+  "overrides": {
+    "moment-jalaali": "^0.10.4"
+  }
+}

+ 13 - 0
frontend/settings.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>3x-ui · Settings</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/settings.js"></script>
+  </body>
+</html>

+ 117 - 0
frontend/src/api/axios-init.js

@@ -0,0 +1,117 @@
+import axios from 'axios';
+import qs from 'qs';
+
+const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
+// Public CSRF endpoint — works pre-login (the panel-scoped
+// /panel/csrf-token sits behind checkLogin and would 401 a fresh
+// login page that hasn't authenticated yet).
+const CSRF_TOKEN_PATH = '/csrf-token';
+
+// Cached session CSRF token. The legacy panel injects it via a
+// <meta name="csrf-token"> tag rendered by Go; the new SPA pages
+// fetch it once from /panel/csrf-token instead. Module-level so
+// every axios POST sees the latest value.
+let csrfToken = null;
+let csrfFetchPromise = null;
+
+function readMetaToken() {
+  return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
+}
+
+// Fetch the token via a bare fetch() (not axios) so the call doesn't
+// recurse through this same interceptor.
+async function fetchCsrfToken() {
+  try {
+    const res = await fetch(CSRF_TOKEN_PATH, {
+      method: 'GET',
+      credentials: 'same-origin',
+      headers: { 'X-Requested-With': 'XMLHttpRequest' },
+    });
+    if (!res.ok) return null;
+    const json = await res.json();
+    return json?.success && typeof json.obj === 'string' ? json.obj : null;
+  } catch (_e) {
+    return null;
+  }
+}
+
+async function ensureCsrfToken() {
+  if (csrfToken) return csrfToken;
+  const meta = readMetaToken();
+  if (meta) {
+    csrfToken = meta;
+    return csrfToken;
+  }
+  if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
+  const fetched = await csrfFetchPromise;
+  csrfFetchPromise = null;
+  if (fetched) csrfToken = fetched;
+  return csrfToken;
+}
+
+// Apply the panel's axios defaults + interceptors. Call once at app
+// startup before any HTTP call goes out.
+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';
+
+  // Seed the cache from the meta tag if a server-rendered page injected
+  // one — saves a round trip on legacy templates that still embed it.
+  csrfToken = readMetaToken();
+
+  axios.interceptors.request.use(
+    async (config) => {
+      config.headers = config.headers || {};
+      const method = (config.method || 'get').toUpperCase();
+      if (!SAFE_METHODS.has(method)) {
+        const token = await ensureCsrfToken();
+        if (token) config.headers['X-CSRF-Token'] = token;
+      }
+      if (config.data instanceof FormData) {
+        config.headers['Content-Type'] = 'multipart/form-data';
+      } else {
+        config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
+      }
+      return config;
+    },
+    (error) => Promise.reject(error),
+  );
+
+  axios.interceptors.response.use(
+    (response) => response,
+    async (error) => {
+      const status = error.response?.status;
+      if (status === 401) {
+        // 401 → session is gone. In production, the panel routes
+        // are gated by Go's checkLogin which redirects to base_path
+        // serving the login page; a reload is enough. In dev, Vite
+        // serves /index.html directly at "/", so a reload would put
+        // 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__ || '/';
+          window.location.href = `${basePath}login.html`;
+        } else {
+          window.location.reload();
+        }
+        return Promise.reject(error);
+      }
+      // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
+      const cfg = error.config;
+      if (status === 403 && cfg && !cfg.__csrfRetried) {
+        csrfToken = null;
+        cfg.__csrfRetried = true;
+        const token = await ensureCsrfToken();
+        if (token) {
+          cfg.headers = cfg.headers || {};
+          cfg.headers['X-CSRF-Token'] = token;
+          // axios re-stringifies on retry, so unwind our qs.stringify before
+          // letting the same request flow through the interceptor again.
+          if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
+          return axios(cfg);
+        }
+      }
+      return Promise.reject(error);
+    },
+  );
+}

+ 9 - 5
web/assets/js/websocket.js → frontend/src/api/websocket.js

@@ -15,7 +15,7 @@
  *   'connected', 'disconnected', 'error', 'message',
  *   plus any server-emitted message type (status, traffic, client_stats, ...).
  */
-class WebSocketClient {
+export class WebSocketClient {
   static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
   static #BASE_RECONNECT_MS = 1000;
   static #MAX_RECONNECT_MS = 30_000;
@@ -140,8 +140,14 @@ class WebSocketClient {
 
   #buildUrl() {
     const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
-    let basePath = this.basePath || '';
-    if (basePath && !basePath.endsWith('/')) basePath += '/';
+    // 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
+    // constructor throws a SyntaxError.
+    let basePath = this.basePath || '/';
+    if (!basePath.startsWith('/')) basePath = '/' + basePath;
+    if (!basePath.endsWith('/')) basePath += '/';
     return `${protocol}//${window.location.host}${basePath}ws`;
   }
 
@@ -223,5 +229,3 @@ class WebSocketClient {
   }
 }
 
-// Global instance — basePath is set by page.html before this script loads.
-window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');

+ 186 - 0
frontend/src/components/AppSidebar.vue

@@ -0,0 +1,186 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  DashboardOutlined,
+  UserOutlined,
+  SettingOutlined,
+  ToolOutlined,
+  ClusterOutlined,
+  LogoutOutlined,
+  CloseOutlined,
+  MenuFoldOutlined,
+} from '@ant-design/icons-vue';
+
+import { currentTheme } from '@/composables/useTheme.js';
+import ThemeSwitch from '@/components/ThemeSwitch.vue';
+
+const { t } = useI18n();
+
+const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
+
+const props = defineProps({
+  // Path prefix (e.g. /custom-base/) the panel is served under. Defaults
+  // to '' which means tab keys end up as '/panel/...'. Pages pass the
+  // value the Go backend gave them (in production via a meta tag).
+  basePath: { type: String, default: '' },
+  // Current request URI so the matching menu item highlights.
+  requestUri: { type: String, default: '' },
+});
+
+// AD-Vue 4 dropped <a-icon :type="x"> in favor of explicit icon
+// imports — keep a small name-to-component map so tab definitions stay
+// declarative.
+const iconByName = {
+  dashboard: DashboardOutlined,
+  user: UserOutlined,
+  setting: SettingOutlined,
+  tool: ToolOutlined,
+  cluster: ClusterOutlined,
+  logout: LogoutOutlined,
+};
+
+// basePath comes from Go (`/` by default, `/myprefix/` when configured) so
+// these concatenations land on absolute paths. In dev we synthesize the prop
+// from a window global which can be empty — force a leading slash so the
+// browser doesn't resolve the link relative to the current pathname (which
+// would turn /panel/settings + 'panel/...' into /panel/panel/...).
+const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
+
+// Labels are i18n-driven so the sidebar matches the locale picked
+// in panel settings without a page reload of the sidebar component.
+const tabs = computed(() => [
+  { key: `${prefix}panel/`,         icon: 'dashboard', title: t('menu.dashboard') },
+  { key: `${prefix}panel/inbounds`, icon: 'user',      title: t('menu.inbounds') },
+  { key: `${prefix}panel/nodes`,    icon: 'cluster',   title: t('menu.nodes') },
+  { key: `${prefix}panel/settings`, icon: 'setting',   title: t('menu.settings') },
+  { key: `${prefix}panel/xray`,     icon: 'tool',      title: t('menu.xray') },
+  { key: `${prefix}logout`,         icon: 'logout',    title: t('logout') },
+]);
+
+const activeTab = ref([props.requestUri]);
+
+const drawerOpen = ref(false);
+const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
+
+function openLink(key) {
+  if (key.startsWith('http')) {
+    window.open(key);
+  } else {
+    window.location.href = key;
+  }
+}
+
+function onCollapse(isCollapsed, type) {
+  // Only persist explicit toggle clicks, not breakpoint-triggered collapses.
+  if (type === 'clickTrigger') {
+    localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
+    collapsed.value = isCollapsed;
+  }
+}
+
+function toggleDrawer() {
+  drawerOpen.value = !drawerOpen.value;
+}
+
+function closeDrawer() {
+  drawerOpen.value = false;
+}
+</script>
+
+<template>
+  <div class="ant-sidebar">
+    <a-layout-sider
+      :theme="currentTheme"
+      collapsible
+      :collapsed="collapsed"
+      breakpoint="md"
+      @collapse="onCollapse"
+    >
+      <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">
+          <component :is="iconByName[tab.icon]" />
+          <span>{{ tab.title }}</span>
+        </a-menu-item>
+      </a-menu>
+    </a-layout-sider>
+
+    <a-drawer
+      placement="left"
+      :closable="false"
+      :open="drawerOpen"
+      :wrap-class-name="currentTheme"
+      :wrap-style="{ padding: 0 }"
+      :style="{ height: '100%' }"
+      @close="closeDrawer"
+    >
+      <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">
+          <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>
+  </div>
+</template>
+
+<style scoped>
+.ant-sidebar > .ant-layout-sider {
+  height: 100%;
+}
+
+.drawer-handle {
+  position: fixed;
+  top: 16px;
+  left: 16px;
+  z-index: 1100;
+  background: rgba(0, 0, 0, 0.55);
+  color: #fff;
+  border: none;
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  cursor: pointer;
+  display: none;
+  align-items: center;
+  justify-content: center;
+}
+
+@media (max-width: 768px) {
+  .drawer-handle {
+    display: inline-flex;
+  }
+
+  /* On mobile the drawer is the menu — hide the inline sider's content
+   * + the collapse trigger so the sider stops taking layout space and
+   * leaves no remnant button next to the page. */
+  .ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-children),
+  .ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-trigger) {
+    display: none;
+  }
+
+  .ant-sidebar > .ant-layout-sider {
+    flex: 0 0 0 !important;
+    max-width: 0 !important;
+    min-width: 0 !important;
+    width: 0 !important;
+  }
+}
+</style>

+ 27 - 0
frontend/src/components/CustomStatistic.vue

@@ -0,0 +1,27 @@
+<script setup>
+defineProps({
+  title: { type: String, default: '' },
+  value: { type: [String, Number], default: '' },
+});
+</script>
+
+<template>
+  <a-statistic :title="title" :value="value">
+    <template #prefix><slot name="prefix" /></template>
+    <template #suffix><slot name="suffix" /></template>
+  </a-statistic>
+</template>
+
+<style scoped>
+:deep(.ant-statistic-content) {
+  font-size: 16px;
+}
+
+:global(body.dark .ant-statistic-content) {
+  color: var(--dark-color-text-primary);
+}
+
+:global(body.dark .ant-statistic-title) {
+  color: rgba(255, 255, 255, 0.55);
+}
+</style>

+ 384 - 0
frontend/src/components/DateTimePicker.vue

@@ -0,0 +1,384 @@
+<script setup>
+import { computed } from 'vue';
+import dayjs from 'dayjs';
+import PersianDatePicker from 'vue3-persian-datetime-picker';
+import { useDatepicker } from '@/composables/useDatepicker.js';
+
+// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
+// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
+// when the panel's "Calendar Type" setting is `jalalian`.
+//
+// The v-model contract matches AD-Vue: the parent works with a dayjs
+// object (or null). For the persian picker we serialize to/from the
+// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
+// know which renderer is active.
+
+const props = defineProps({
+  value: { type: [Object, null], default: null },
+  showTime: { type: Boolean, default: true },
+  format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
+  placeholder: { type: String, default: '' },
+  disabled: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:value']);
+
+const { datepicker } = useDatepicker();
+const isJalali = computed(() => datepicker.value === 'jalalian');
+
+const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+
+// Persian picker's display format — `j…` tokens come from moment-jalaali
+// and render Jalali year/month/day.
+const persianDisplayFormat = computed(() =>
+  props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
+);
+
+// Persian picker stores the date as a Gregorian string in the format
+// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
+// dayjs(...) round-trips cleanly.
+const stringValue = computed({
+  get() {
+    const v = props.value;
+    if (!v) return '';
+    return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
+  },
+  set(next) {
+    if (!next) {
+      emit('update:value', null);
+      return;
+    }
+    const parsed = dayjs(next, ISO_FORMAT);
+    emit('update:value', parsed.isValid() ? parsed : null);
+  },
+});
+
+function onAntChange(next) {
+  emit('update:value', next || null);
+}
+</script>
+
+<template>
+  <PersianDatePicker
+    v-if="isJalali"
+    v-model="stringValue"
+    :format="ISO_FORMAT"
+    :display-format="persianDisplayFormat"
+    :placeholder="placeholder"
+    :disabled="disabled"
+    color="#1677ff"
+    auto-submit
+    append-to="body"
+    input-class="ant-input persian-datepicker-input"
+    class="jalali-datepicker"
+  />
+  <a-date-picker
+    v-else
+    :value="value"
+    :show-time="showTime ? { format: 'HH:mm:ss' } : false"
+    :format="format"
+    :placeholder="placeholder"
+    :disabled="disabled"
+    :style="{ width: '100%' }"
+    @update:value="onAntChange"
+  />
+</template>
+
+<style scoped>
+.jalali-datepicker {
+  width: 100%;
+}
+</style>
+
+<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
+     by default (its tokens live in JS), so we hardcode hexes per theme
+     class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
+     for the neutral ultra-dark variant. The popup stays inside the
+     wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
+<style>
+/* ===== Light (default) =================================================== */
+
+.persian-datepicker-input {
+  width: 100%;
+  box-sizing: border-box;
+  padding: 4px 11px;
+  font-size: 14px;
+  border: 1px solid #d9d9d9;
+  border-radius: 6px;
+  background: #fff;
+  color: rgba(0, 0, 0, 0.88);
+  transition: border-color 0.2s, box-shadow 0.2s;
+}
+
+.persian-datepicker-input:hover {
+  border-color: #4096ff;
+}
+
+.persian-datepicker-input:focus {
+  border-color: #1677ff;
+  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
+  outline: none;
+}
+
+/* Light theme keeps the picker's brand-blue calendar button (set via
+ * inline style on .vpd-icon-btn) — only its border + corner radius are
+ * normalized so it sits flush with the input. Dark/ultra-dark themes
+ * below override the inline blue so the control matches the form. */
+.vpd-main .vpd-icon-btn {
+  color: #fff;
+  border: 1px solid transparent;
+  border-radius: 6px 0 0 6px;
+}
+
+/* Match the input's left edge (no rounded left, no double border at the
+ * seam) so it sits flush against the icon-btn. */
+.persian-datepicker-input {
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+.vpd-main .vpd-clear-btn {
+  color: rgba(0, 0, 0, 0.45);
+  background: transparent;
+}
+
+/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
+ * fits flush. Don't add `border` here — box-sizing: border-box would
+ * eat 2px from the content width and the 7th day-cell of each row
+ * wraps. Use box-shadow + a wider radius for the visual edge instead. */
+.vpd-wrapper .vpd-content {
+  background: #fff;
+  color: rgba(0, 0, 0, 0.88);
+  box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
+              0 3px 6px -4px rgba(0, 0, 0, 0.12),
+              0 9px 28px 8px rgba(0, 0, 0, 0.05);
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.vpd-wrapper .vpd-header {
+  background: #1677ff;
+  color: #fff;
+  border-radius: 8px 8px 0 0;
+}
+
+.vpd-wrapper .vpd-header .vpd-year-label,
+.vpd-wrapper .vpd-header .vpd-date,
+.vpd-wrapper .vpd-header .vpd-locales li {
+  color: #fff;
+}
+
+.vpd-wrapper .vpd-body {
+  background: #fff;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.vpd-wrapper .vpd-body .vpd-month-label,
+.vpd-wrapper .vpd-body .vpd-month-label > span {
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.vpd-wrapper .vpd-body .vpd-week,
+.vpd-wrapper .vpd-body .vpd-weekday {
+  color: rgba(0, 0, 0, 0.55);
+}
+
+.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
+.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
+  color: rgba(0, 0, 0, 0.65);
+}
+
+/* The picker's <arrow> component renders an inline SVG with a hardcoded
+ * `fill="#000"` attribute. Override the path fill via CSS so the arrow
+ * is visible in every theme. */
+.vpd-wrapper .vpd-next svg path,
+.vpd-wrapper .vpd-prev svg path {
+  fill: rgba(0, 0, 0, 0.65);
+}
+
+.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
+.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
+  fill: #1677ff;
+}
+
+/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
+ * is invisible on white and dark themes alike. Reset the day text color
+ * across all states so days are always readable. */
+.vpd-wrapper .vpd-day,
+.vpd-wrapper .vpd-day .vpd-day-text {
+  color: rgba(0, 0, 0, 0.88) !important;
+}
+
+.vpd-wrapper .vpd-day[disabled='true'],
+.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
+  color: rgba(0, 0, 0, 0.25) !important;
+}
+
+.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
+.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
+  color: #fff !important;
+}
+
+.vpd-wrapper .vpd-actions button {
+  color: rgba(0, 0, 0, 0.88);
+  background: transparent;
+}
+
+.vpd-wrapper .vpd-actions button:hover {
+  background: rgba(0, 0, 0, 0.04);
+  color: #1677ff;
+}
+
+.vpd-wrapper .vpd-addon-list,
+.vpd-wrapper .vpd-addon-list-content {
+  background: #fff;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.vpd-wrapper .vpd-addon-list-item {
+  color: rgba(0, 0, 0, 0.88);
+  border-color: #fff;
+}
+
+.vpd-wrapper .vpd-addon-list-item.vpd-selected,
+.vpd-wrapper .vpd-addon-list-item:hover {
+  background: rgba(0, 0, 0, 0.04);
+}
+
+.vpd-wrapper .vpd-close-addon {
+  color: rgba(0, 0, 0, 0.65);
+  background: rgba(0, 0, 0, 0.06);
+}
+
+/* ===== Dark (navy) ======================================================= */
+
+body.dark .persian-datepicker-input {
+  background: #142340;
+  border-color: #1f3358;
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .persian-datepicker-input:hover {
+  border-color: #4096ff;
+}
+
+body.dark .persian-datepicker-input:focus {
+  border-color: #1677ff;
+  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
+}
+
+body.dark .vpd-main .vpd-icon-btn {
+  background: rgba(255, 255, 255, 0.04) !important;
+  border: 1px solid #1f3358 !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;
+  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),
+              0 9px 28px 8px rgba(0, 0, 0, 0.2);
+}
+
+body.dark .vpd-wrapper .vpd-body {
+  background: #1a2c4d;
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .vpd-wrapper .vpd-body .vpd-month-label,
+body.dark .vpd-wrapper .vpd-body .vpd-month-label > span {
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .vpd-wrapper .vpd-body .vpd-week,
+body.dark .vpd-wrapper .vpd-body .vpd-weekday {
+  color: rgba(255, 255, 255, 0.55);
+}
+
+body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
+body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
+  color: rgba(255, 255, 255, 0.65);
+}
+
+body.dark .vpd-wrapper .vpd-next svg path,
+body.dark .vpd-wrapper .vpd-prev svg path {
+  fill: rgba(255, 255, 255, 0.75);
+}
+
+body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
+body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
+  fill: #4096ff;
+}
+
+body.dark .vpd-wrapper .vpd-day,
+body.dark .vpd-wrapper .vpd-day .vpd-day-text {
+  color: rgba(255, 255, 255, 0.88) !important;
+}
+
+body.dark .vpd-wrapper .vpd-day[disabled='true'],
+body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
+  color: rgba(255, 255, 255, 0.25) !important;
+}
+
+body.dark .vpd-wrapper .vpd-actions button {
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .vpd-wrapper .vpd-actions button:hover {
+  background: rgba(255, 255, 255, 0.06);
+}
+
+body.dark .vpd-wrapper .vpd-addon-list,
+body.dark .vpd-wrapper .vpd-addon-list-content {
+  background: #1a2c4d;
+  color: rgba(255, 255, 255, 0.88);
+}
+
+body.dark .vpd-wrapper .vpd-addon-list-item {
+  color: rgba(255, 255, 255, 0.88);
+  border-color: transparent;
+}
+
+body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
+body.dark .vpd-wrapper .vpd-addon-list-item:hover {
+  background: rgba(255, 255, 255, 0.06);
+}
+
+body.dark .vpd-wrapper .vpd-close-addon {
+  color: rgba(255, 255, 255, 0.65);
+  background: rgba(255, 255, 255, 0.08);
+}
+
+/* ===== Ultra-dark (neutral black) ======================================= */
+
+html[data-theme='ultra-dark'] .persian-datepicker-input {
+  background: #0a0a0a;
+  border-color: #303030;
+  color: rgba(255, 255, 255, 0.88);
+}
+
+html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
+  background: rgba(255, 255, 255, 0.04) !important;
+  border: 1px solid #303030 !important;
+  border-right: none !important;
+  border-radius: 6px 0 0 6px !important;
+  color: rgba(255, 255, 255, 0.75) !important;
+}
+
+html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
+  background: #141414;
+  color: rgba(255, 255, 255, 0.88);
+}
+
+html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
+  background: #141414;
+}
+
+html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
+html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
+  background: #141414;
+}
+</style>

+ 542 - 0
frontend/src/components/FinalMaskForm.vue

@@ -0,0 +1,542 @@
+<script setup>
+import { computed } from 'vue';
+import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
+import { RandomUtil } from '@/utils';
+import { Protocols } from '@/models/inbound.js';
+
+// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
+// inbound and outbound modals — they share the same StreamSettings
+// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
+// component handles both. The host modal passes its protocol through
+// so we know whether to show only the Hysteria-specific UDP types.
+const props = defineProps({
+  stream: { type: Object, required: true },
+  protocol: { type: String, default: '' },
+});
+
+const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
+const network = computed(() => props.stream?.network || '');
+
+const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
+const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
+const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
+
+// Reset the per-row settings shape when the user picks a different
+// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
+function changeMaskType(mask, type) {
+  mask.type = type;
+  mask.settings = mask._getDefaultSettings(type, {});
+}
+
+// Special case from the legacy form: switching a UDP mask to xdns
+// shrinks the kcp MTU; everything else needs the default 1350.
+function changeUdpMaskType(mask, type) {
+  changeMaskType(mask, type);
+  if (network.value === 'kcp' && props.stream.kcp) {
+    props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
+  }
+}
+
+// header-custom and noise rows share the same per-item shape — the
+// type select rewires the packet field. Pulled out so the click
+// handlers in the template stay readable.
+function changeItemType(item, type) {
+  item.type = type;
+  if (type === 'base64') item.packet = RandomUtil.randomBase64();
+  else if (type === 'array') { item.rand = 0; item.packet = []; }
+  else item.packet = '';
+}
+
+function addUdpMaskWithDefault() {
+  const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
+  props.stream.addUdpMask(def);
+}
+
+function newClientServerItem() {
+  return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
+}
+
+function newUdpClientServerItem() {
+  return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
+}
+
+function newNoiseItem() {
+  return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
+}
+</script>
+
+<template>
+  <a-form
+    v-if="showTcp || showUdp || showQuic"
+    :colon="false"
+    :label-col="{ md: { span: 8 } }"
+    :wrapper-col="{ md: { span: 14 } }"
+  >
+    <!-- ============================== TCP MASKS ============================== -->
+    <template v-if="showTcp">
+      <a-form-item label="TCP Masks">
+        <a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+      </a-form-item>
+
+      <template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
+        <a-divider :style="{ margin: '0' }">
+          TCP Mask {{ mIdx + 1 }}
+          <DeleteOutlined
+            :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+            @click="stream.delTcpMask(mIdx)"
+          />
+        </a-divider>
+
+        <a-form-item label="Type">
+          <a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
+            <a-select-option value="fragment">Fragment</a-select-option>
+            <a-select-option value="header-custom">Header Custom</a-select-option>
+            <a-select-option value="sudoku">Sudoku</a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <!-- Fragment -->
+        <template v-if="mask.type === 'fragment'">
+          <a-form-item label="Packets">
+            <a-select v-model:value="mask.settings.packets">
+              <a-select-option value="tlshello">tlshello</a-select-option>
+              <a-select-option value="1-3">1-3</a-select-option>
+              <a-select-option value="1-5">1-5</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Length">
+            <a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
+          </a-form-item>
+          <a-form-item label="Delay">
+            <a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
+          </a-form-item>
+          <a-form-item label="Max Split">
+            <a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
+          </a-form-item>
+        </template>
+
+        <!-- Sudoku -->
+        <template v-if="mask.type === 'sudoku'">
+          <a-form-item label="Password">
+            <a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
+          </a-form-item>
+          <a-form-item label="ASCII">
+            <a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
+          </a-form-item>
+          <a-form-item label="Custom Table">
+            <a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
+          </a-form-item>
+          <a-form-item label="Custom Tables">
+            <a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
+          </a-form-item>
+          <a-form-item label="Padding Min">
+            <a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
+          </a-form-item>
+          <a-form-item label="Padding Max">
+            <a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
+          </a-form-item>
+        </template>
+
+        <!-- Header Custom — clients/servers as 2D groups -->
+        <template v-if="mask.type === 'header-custom'">
+          <!-- Clients -->
+          <a-form-item label="Clients">
+            <a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
+              <template #icon><PlusOutlined /></template>
+            </a-button>
+          </a-form-item>
+          <template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
+            <a-divider :style="{ margin: '0' }">
+              Clients Group {{ gi + 1 }}
+              <DeleteOutlined
+                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.clients.splice(gi, 1)"
+              />
+            </a-divider>
+            <template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
+              <a-form-item label="Type">
+                <a-select :value="item.type" @change="(t) => changeItemType(item, t)">
+                  <a-select-option value="array">Array</a-select-option>
+                  <a-select-option value="str">String</a-select-option>
+                  <a-select-option value="hex">Hex</a-select-option>
+                  <a-select-option value="base64">Base64</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Delay (ms)">
+                <a-input-number v-model:value="item.delay" :min="0" />
+              </a-form-item>
+              <template v-if="item.type === 'array'">
+                <a-form-item label="Rand">
+                  <a-input-number v-model:value="item.rand" :min="0" />
+                </a-form-item>
+                <a-form-item label="Rand Range">
+                  <a-input v-model:value="item.randRange" placeholder="0-255" />
+                </a-form-item>
+              </template>
+              <a-form-item v-else label="Packet">
+                <a-input-group v-if="item.type === 'base64'" compact>
+                  <a-input
+                    v-model:value="item.packet"
+                    placeholder="binary data"
+                    :style="{ width: 'calc(100% - 32px)' }"
+                  />
+                  <a-button @click="item.packet = RandomUtil.randomBase64()">
+                    <template #icon><ReloadOutlined /></template>
+                  </a-button>
+                </a-input-group>
+                <a-input v-else v-model:value="item.packet" placeholder="binary data" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- Servers -->
+          <a-form-item label="Servers">
+            <a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
+              <template #icon><PlusOutlined /></template>
+            </a-button>
+          </a-form-item>
+          <template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
+            <a-divider :style="{ margin: '0' }">
+              Servers Group {{ gi + 1 }}
+              <DeleteOutlined
+                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.servers.splice(gi, 1)"
+              />
+            </a-divider>
+            <template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
+              <a-form-item label="Type">
+                <a-select :value="item.type" @change="(t) => changeItemType(item, t)">
+                  <a-select-option value="array">Array</a-select-option>
+                  <a-select-option value="str">String</a-select-option>
+                  <a-select-option value="hex">Hex</a-select-option>
+                  <a-select-option value="base64">Base64</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Delay (ms)">
+                <a-input-number v-model:value="item.delay" :min="0" />
+              </a-form-item>
+              <template v-if="item.type === 'array'">
+                <a-form-item label="Rand">
+                  <a-input-number v-model:value="item.rand" :min="0" />
+                </a-form-item>
+                <a-form-item label="Rand Range">
+                  <a-input v-model:value="item.randRange" placeholder="0-255" />
+                </a-form-item>
+              </template>
+              <a-form-item v-else label="Packet">
+                <a-input-group v-if="item.type === 'base64'" compact>
+                  <a-input
+                    v-model:value="item.packet"
+                    placeholder="binary data"
+                    :style="{ width: 'calc(100% - 32px)' }"
+                  />
+                  <a-button @click="item.packet = RandomUtil.randomBase64()">
+                    <template #icon><ReloadOutlined /></template>
+                  </a-button>
+                </a-input-group>
+                <a-input v-else v-model:value="item.packet" placeholder="binary data" />
+              </a-form-item>
+            </template>
+          </template>
+        </template>
+      </template>
+    </template>
+
+    <!-- ============================== UDP MASKS ============================== -->
+    <template v-if="showUdp">
+      <a-form-item label="UDP Masks">
+        <a-button type="primary" size="small" @click="addUdpMaskWithDefault">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+      </a-form-item>
+
+      <template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
+        <a-divider :style="{ margin: '0' }">
+          UDP Mask {{ mIdx + 1 }}
+          <DeleteOutlined
+            :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+            @click="stream.delUdpMask(mIdx)"
+          />
+        </a-divider>
+
+        <a-form-item label="Type">
+          <a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
+            <template v-if="isHysteria">
+              <a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
+            </template>
+            <template v-else>
+              <a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
+              <a-select-option value="header-dns">Header DNS</a-select-option>
+              <a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
+              <a-select-option value="header-srtp">Header SRTP</a-select-option>
+              <a-select-option value="header-utp">Header uTP</a-select-option>
+              <a-select-option value="header-wechat">Header WeChat Video</a-select-option>
+              <a-select-option value="header-wireguard">Header WireGuard</a-select-option>
+              <a-select-option value="mkcp-original">mKCP Original</a-select-option>
+              <a-select-option value="xdns">xDNS</a-select-option>
+              <a-select-option value="xicmp">xICMP</a-select-option>
+              <a-select-option value="header-custom">Header Custom</a-select-option>
+              <a-select-option value="noise">Noise</a-select-option>
+            </template>
+          </a-select>
+        </a-form-item>
+
+        <a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
+          <a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
+        </a-form-item>
+        <a-form-item v-if="mask.type === 'header-dns'" label="Domain">
+          <a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
+        </a-form-item>
+        <a-form-item v-if="mask.type === 'xdns'" label="Domains">
+          <a-select
+            v-model:value="mask.settings.domains"
+            mode="tags"
+            :style="{ width: '100%' }"
+            :token-separators="[',']"
+            placeholder="e.g., www.example.com"
+          />
+        </a-form-item>
+
+        <!-- Noise -->
+        <template v-if="mask.type === 'noise'">
+          <a-form-item label="Reset">
+            <a-input-number v-model:value="mask.settings.reset" :min="0" />
+          </a-form-item>
+          <a-form-item label="Noise">
+            <a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
+              <template #icon><PlusOutlined /></template>
+            </a-button>
+          </a-form-item>
+          <template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
+            <a-divider :style="{ margin: '0' }">
+              Noise {{ ni + 1 }}
+              <DeleteOutlined
+                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.noise.splice(ni, 1)"
+              />
+            </a-divider>
+            <a-form-item label="Type">
+              <a-select :value="n.type" @change="(t) => changeItemType(n, t)">
+                <a-select-option value="array">Array</a-select-option>
+                <a-select-option value="str">String</a-select-option>
+                <a-select-option value="hex">Hex</a-select-option>
+                <a-select-option value="base64">Base64</a-select-option>
+              </a-select>
+            </a-form-item>
+            <template v-if="n.type === 'array'">
+              <a-form-item label="Rand">
+                <a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
+              </a-form-item>
+              <a-form-item label="Rand Range">
+                <a-input v-model:value="n.randRange" placeholder="0-255" />
+              </a-form-item>
+            </template>
+            <a-form-item v-else label="Packet">
+              <a-input-group v-if="n.type === 'base64'" compact>
+                <a-input
+                  v-model:value="n.packet"
+                  placeholder="binary data"
+                  :style="{ width: 'calc(100% - 32px)' }"
+                />
+                <a-button @click="n.packet = RandomUtil.randomBase64()">
+                  <template #icon><ReloadOutlined /></template>
+                </a-button>
+              </a-input-group>
+              <a-input v-else v-model:value="n.packet" placeholder="binary data" />
+            </a-form-item>
+            <a-form-item label="Delay">
+              <a-input v-model:value="n.delay" placeholder="10-20" />
+            </a-form-item>
+          </template>
+        </template>
+
+        <!-- Header Custom (UDP) — flat client/server lists -->
+        <template v-if="mask.type === 'header-custom'">
+          <a-form-item label="Client">
+            <a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
+              <template #icon><PlusOutlined /></template>
+            </a-button>
+          </a-form-item>
+          <template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
+            <a-divider :style="{ margin: '0' }">
+              Client {{ ci + 1 }}
+              <DeleteOutlined
+                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.client.splice(ci, 1)"
+              />
+            </a-divider>
+            <a-form-item label="Type">
+              <a-select :value="c.type" @change="(t) => changeItemType(c, t)">
+                <a-select-option value="array">Array</a-select-option>
+                <a-select-option value="str">String</a-select-option>
+                <a-select-option value="hex">Hex</a-select-option>
+                <a-select-option value="base64">Base64</a-select-option>
+              </a-select>
+            </a-form-item>
+            <template v-if="c.type === 'array'">
+              <a-form-item label="Rand">
+                <a-input-number v-model:value="c.rand" />
+              </a-form-item>
+              <a-form-item label="Rand Range">
+                <a-input v-model:value="c.randRange" placeholder="0-255" />
+              </a-form-item>
+            </template>
+            <a-form-item v-else label="Packet">
+              <a-input-group v-if="c.type === 'base64'" compact>
+                <a-input
+                  v-model:value="c.packet"
+                  placeholder="binary data"
+                  :style="{ width: 'calc(100% - 32px)' }"
+                />
+                <a-button @click="c.packet = RandomUtil.randomBase64()">
+                  <template #icon><ReloadOutlined /></template>
+                </a-button>
+              </a-input-group>
+              <a-input v-else v-model:value="c.packet" placeholder="binary data" />
+            </a-form-item>
+          </template>
+
+          <a-divider :style="{ margin: '0' }" />
+          <a-form-item label="Server">
+            <a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
+              <template #icon><PlusOutlined /></template>
+            </a-button>
+          </a-form-item>
+          <template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
+            <a-divider :style="{ margin: '0' }">
+              Server {{ si + 1 }}
+              <DeleteOutlined
+                :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
+                @click="mask.settings.server.splice(si, 1)"
+              />
+            </a-divider>
+            <a-form-item label="Type">
+              <a-select :value="s.type" @change="(t) => changeItemType(s, t)">
+                <a-select-option value="array">Array</a-select-option>
+                <a-select-option value="str">String</a-select-option>
+                <a-select-option value="hex">Hex</a-select-option>
+                <a-select-option value="base64">Base64</a-select-option>
+              </a-select>
+            </a-form-item>
+            <template v-if="s.type === 'array'">
+              <a-form-item label="Rand">
+                <a-input-number v-model:value="s.rand" />
+              </a-form-item>
+              <a-form-item label="Rand Range">
+                <a-input v-model:value="s.randRange" placeholder="0-255" />
+              </a-form-item>
+            </template>
+            <a-form-item v-else label="Packet">
+              <a-input-group v-if="s.type === 'base64'" compact>
+                <a-input
+                  v-model:value="s.packet"
+                  placeholder="binary data"
+                  :style="{ width: 'calc(100% - 32px)' }"
+                />
+                <a-button @click="s.packet = RandomUtil.randomBase64()">
+                  <template #icon><ReloadOutlined /></template>
+                </a-button>
+              </a-input-group>
+              <a-input v-else v-model:value="s.packet" placeholder="binary data" />
+            </a-form-item>
+          </template>
+        </template>
+
+        <!-- xICMP -->
+        <template v-if="mask.type === 'xicmp'">
+          <a-form-item label="IP">
+            <a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
+          </a-form-item>
+          <a-form-item label="ID">
+            <a-input-number v-model:value="mask.settings.id" :min="0" />
+          </a-form-item>
+        </template>
+      </template>
+    </template>
+
+    <!-- ============================== QUIC PARAMS ============================== -->
+    <template v-if="showQuic">
+      <a-form-item label="QUIC Params">
+        <a-switch v-model:checked="stream.finalmask.enableQuicParams" />
+      </a-form-item>
+      <template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
+        <a-form-item label="Congestion">
+          <a-select v-model:value="stream.finalmask.quicParams.congestion">
+            <a-select-option value="reno">Reno</a-select-option>
+            <a-select-option value="bbr">BBR</a-select-option>
+            <a-select-option value="brutal">Brutal</a-select-option>
+            <a-select-option value="force-brutal">Force Brutal</a-select-option>
+          </a-select>
+        </a-form-item>
+        <a-form-item label="Debug">
+          <a-switch v-model:checked="stream.finalmask.quicParams.debug" />
+        </a-form-item>
+        <template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
+          <a-form-item label="Brutal Up">
+            <a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
+          </a-form-item>
+          <a-form-item label="Brutal Down">
+            <a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
+          </a-form-item>
+        </template>
+        <a-form-item label="UDP Hop">
+          <a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
+        </a-form-item>
+        <template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
+          <a-form-item label="Hop Ports">
+            <a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
+          </a-form-item>
+          <a-form-item label="Hop Interval (s)">
+            <a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
+          </a-form-item>
+        </template>
+        <a-form-item label="Max Idle Timeout (s)">
+          <a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
+        </a-form-item>
+        <a-form-item label="Keep Alive Period (s)">
+          <a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
+        </a-form-item>
+        <a-form-item label="Disable Path MTU Dis">
+          <a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
+        </a-form-item>
+        <a-form-item label="Max Incoming Streams">
+          <a-input-number
+            v-model:value="stream.finalmask.quicParams.maxIncomingStreams"
+            :min="8"
+            placeholder="1024 = default"
+          />
+        </a-form-item>
+        <a-form-item label="Init Stream Window">
+          <a-input-number
+            v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow"
+            :min="16384"
+            placeholder="8388608 = default"
+          />
+        </a-form-item>
+        <a-form-item label="Max Stream Window">
+          <a-input-number
+            v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow"
+            :min="16384"
+            placeholder="8388608 = default"
+          />
+        </a-form-item>
+        <a-form-item label="Init Conn Window">
+          <a-input-number
+            v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow"
+            :min="16384"
+            placeholder="20971520 = default"
+          />
+        </a-form-item>
+        <a-form-item label="Max Conn Window">
+          <a-input-number
+            v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow"
+            :min="16384"
+            placeholder="20971520 = default"
+          />
+        </a-form-item>
+      </template>
+    </template>
+  </a-form>
+</template>

+ 25 - 0
frontend/src/components/InfinityIcon.vue

@@ -0,0 +1,25 @@
+<script setup>
+// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
+// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
+// so the inbound list and client row table use this SVG instead. The
+// path matches what the legacy panel embedded.
+defineProps({
+  width: { type: [String, Number], default: 14 },
+  height: { type: [String, Number], default: 10 },
+});
+</script>
+
+<template>
+  <svg
+    :width="width"
+    :height="height"
+    viewBox="0 0 640 512"
+    fill="currentColor"
+    aria-hidden="true"
+    style="vertical-align: -1px; display: inline-block;"
+  >
+    <path
+      d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
+    />
+  </svg>
+</template>

+ 70 - 0
frontend/src/components/PromptModal.vue

@@ -0,0 +1,70 @@
+<script setup>
+import { ref, watch } from 'vue';
+
+// Generic prompt modal — used by features like "import inbound" that
+// need a free-form text/textarea input and a confirm callback. The
+// parent owns the action; this component only surfaces the value via
+// the `confirm` event when the user clicks OK.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  title: { type: String, default: '' },
+  okText: { type: String, default: 'OK' },
+  // 'text' = single-line input; 'textarea' = multi-line.
+  type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
+  initialValue: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const value = ref('');
+
+watch(() => props.open, (next) => {
+  if (next) value.value = props.initialValue;
+});
+
+function close() { emit('update:open', false); }
+function ok() { emit('confirm', value.value); }
+
+// Enter submits when single-line; ctrl+S submits in textarea mode
+// (matches legacy keybindings).
+function onKeydown(e) {
+  if (props.type !== 'textarea' && e.key === 'Enter') {
+    e.preventDefault();
+    ok();
+    return;
+  }
+  if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
+    e.preventDefault();
+    ok();
+  }
+}
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    :title="title"
+    :ok-text="okText"
+    cancel-text="Cancel"
+    :mask-closable="false"
+    :confirm-loading="loading"
+    @ok="ok"
+    @cancel="close"
+  >
+    <a-textarea
+      v-if="type === 'textarea'"
+      v-model:value="value"
+      :auto-size="{ minRows: 10, maxRows: 20 }"
+      autofocus
+      @keydown="onKeydown"
+    />
+    <a-input
+      v-else
+      v-model:value="value"
+      autofocus
+      @keydown="onKeydown"
+    />
+  </a-modal>
+</template>

+ 31 - 0
frontend/src/components/SettingListItem.vue

@@ -0,0 +1,31 @@
+<script setup>
+import { computed } from 'vue';
+
+const props = defineProps({
+  paddings: {
+    type: String,
+    default: 'default',
+    validator: (value) => ['small', 'default'].includes(value),
+  },
+});
+
+const padding = computed(() =>
+  props.paddings === 'small' ? '10px 20px !important' : '20px !important',
+);
+</script>
+
+<template>
+  <a-list-item :style="{ padding }">
+    <a-row :gutter="[8, 16]">
+      <a-col :lg="24" :xl="12">
+        <a-list-item-meta>
+          <template #title><slot name="title" /></template>
+          <template #description><slot name="description" /></template>
+        </a-list-item-meta>
+      </a-col>
+      <a-col :lg="24" :xl="12">
+        <slot name="control" />
+      </a-col>
+    </a-row>
+  </a-list-item>
+</template>

+ 347 - 0
frontend/src/components/Sparkline.vue

@@ -0,0 +1,347 @@
+<script setup>
+import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
+
+const props = defineProps({
+  data: { type: Array, required: true },
+  labels: { type: Array, default: () => [] },
+  vbWidth: { type: Number, default: 320 },
+  height: { type: Number, default: 80 },
+  stroke: { type: String, default: '#008771' },
+  strokeWidth: { type: Number, default: 2 },
+  maxPoints: { type: Number, default: 120 },
+  showGrid: { type: Boolean, default: true },
+  gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
+  fillOpacity: { type: Number, default: 0.15 },
+  showMarker: { type: Boolean, default: true },
+  markerRadius: { type: Number, default: 2.8 },
+  showAxes: { type: Boolean, default: false },
+  yTickStep: { type: Number, default: 25 },
+  tickCountX: { type: Number, default: 4 },
+  paddingLeft: { type: Number, default: 32 },
+  paddingRight: { type: Number, default: 6 },
+  paddingTop: { type: Number, default: 6 },
+  paddingBottom: { type: Number, default: 20 },
+  showTooltip: { type: Boolean, default: false },
+  // Value-range customization. When valueMax is null the chart auto-scales
+  // to the running max of the data (useful for unbounded series like
+  // network throughput or online clients). Defaults preserve the legacy
+  // 0..100 percent behavior so existing callers don't need to change.
+  valueMin: { type: Number, default: 0 },
+  valueMax: { type: [Number, null], default: 100 },
+  // Y-axis tick formatter. Receives the raw value, returns the label.
+  // tooltipFormatter formats the hover-readout; falls back to yFormatter.
+  yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
+  tooltipFormatter: { type: Function, default: null },
+});
+
+const hoverIdx = ref(-1);
+
+// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
+// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
+// stretches the X axis and squashes axis text horizontally on narrow
+// containers (mobile). Falls back to the prop until the first measure.
+const svgRef = ref(null);
+const measuredWidth = ref(0);
+const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
+
+let resizeObserver = null;
+function measure() {
+  const el = svgRef.value;
+  if (!el) return;
+  const w = el.getBoundingClientRect?.().width || 0;
+  if (w > 0) measuredWidth.value = Math.round(w);
+}
+onMounted(() => {
+  measure();
+  if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
+    resizeObserver = new ResizeObserver(measure);
+    resizeObserver.observe(svgRef.value);
+  } else {
+    window.addEventListener('resize', measure);
+  }
+});
+onBeforeUnmount(() => {
+  if (resizeObserver) resizeObserver.disconnect();
+  else window.removeEventListener('resize', measure);
+});
+
+const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
+const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
+const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
+const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
+
+const dataSlice = computed(() => {
+  const n = nPoints.value;
+  if (n === 0) return [];
+  return props.data.slice(props.data.length - n);
+});
+
+const labelsSlice = computed(() => {
+  const n = nPoints.value;
+  if (!props.labels?.length || n === 0) return [];
+  const start = Math.max(0, props.labels.length - n);
+  return props.labels.slice(start);
+});
+
+// Resolved domain. When valueMax is null we auto-scale; pad the upper
+// bound by 10% so the line never touches the top edge — looks more
+// natural and gives the axis a sane ceiling. Floor the dynamic range
+// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
+const yDomain = computed(() => {
+  const min = props.valueMin;
+  if (props.valueMax != null) return { min, max: props.valueMax };
+  let max = min;
+  for (const v of dataSlice.value) {
+    const n = Number(v);
+    if (Number.isFinite(n) && n > max) max = n;
+  }
+  if (max <= min) max = min + 1;
+  return { min, max: max * 1.1 };
+});
+
+function project(v) {
+  const { min, max } = yDomain.value;
+  const span = max - min;
+  if (span <= 0) return props.paddingTop + drawHeight.value;
+  const clipped = Math.max(min, Math.min(max, Number(v) || 0));
+  const ratio = (clipped - min) / span;
+  return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
+}
+
+const pointsArr = computed(() => {
+  const n = nPoints.value;
+  if (n === 0) return [];
+  const slice = dataSlice.value;
+  const w = drawWidth.value;
+  const dx = n > 1 ? w / (n - 1) : 0;
+  return slice.map((v, i) => {
+    const x = Math.round(props.paddingLeft + i * dx);
+    return [x, project(v)];
+  });
+});
+
+const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
+
+const areaPath = computed(() => {
+  if (pointsArr.value.length === 0) return '';
+  const first = pointsArr.value[0];
+  const last = pointsArr.value[pointsArr.value.length - 1];
+  const baseY = props.paddingTop + drawHeight.value;
+  const line = pointsStr.value.replace(/ /g, ' L ');
+  return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
+});
+
+const gridLines = computed(() => {
+  if (!props.showGrid) return [];
+  const h = drawHeight.value;
+  const w = drawWidth.value;
+  return [0, 0.25, 0.5, 0.75, 1].map((r) => {
+    const y = Math.round(props.paddingTop + h * r);
+    return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
+  });
+});
+
+const lastPoint = computed(() => {
+  if (pointsArr.value.length === 0) return null;
+  return pointsArr.value[pointsArr.value.length - 1];
+});
+
+// Y-axis tick rendering. We pick a small number of evenly spaced values
+// inside the resolved domain and run them through yFormatter — that's
+// what makes "MB/s" / "clients" / "%" all render correctly without the
+// caller having to subclass the component.
+const yTicks = computed(() => {
+  if (!props.showAxes) return [];
+  const { min, max } = yDomain.value;
+  const out = [];
+  // For percent-style domains keep the legacy fixed step; otherwise
+  // default to 4 evenly spaced ticks (5 lines including the bottom).
+  if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
+    for (let p = min; p <= max; p += props.yTickStep) {
+      const y = project(p);
+      out.push({ y, label: props.yFormatter(p) });
+    }
+    return out;
+  }
+  const ticks = 5;
+  for (let i = 0; i < ticks; i++) {
+    const v = min + ((max - min) * i) / (ticks - 1);
+    out.push({ y: project(v), label: props.yFormatter(v) });
+  }
+  return out;
+});
+
+const xTicks = computed(() => {
+  if (!props.showAxes) return [];
+  const labels = labelsSlice.value;
+  const n = nPoints.value;
+  if (n === 0) return [];
+  const m = Math.max(2, props.tickCountX);
+  const w = drawWidth.value;
+  const dx = n > 1 ? w / (n - 1) : 0;
+  const out = [];
+  for (let i = 0; i < m; i++) {
+    const idx = Math.round((i * (n - 1)) / (m - 1));
+    const label = labels[idx] != null ? String(labels[idx]) : String(idx);
+    const x = Math.round(props.paddingLeft + idx * dx);
+    out.push({ x, label });
+  }
+  return out;
+});
+
+function onMouseMove(evt) {
+  if (!props.showTooltip || pointsArr.value.length === 0) return;
+  const rect = evt.currentTarget.getBoundingClientRect();
+  const px = evt.clientX - rect.left;
+  const x = (px / rect.width) * effectiveVbWidth.value;
+  const n = nPoints.value;
+  const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
+  const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
+  hoverIdx.value = idx;
+}
+
+function onMouseLeave() {
+  hoverIdx.value = -1;
+}
+
+function fmtHoverText() {
+  const idx = hoverIdx.value;
+  if (idx < 0 || idx >= dataSlice.value.length) return '';
+  const raw = Number(dataSlice.value[idx] || 0);
+  const fmt = props.tooltipFormatter || props.yFormatter;
+  const val = fmt(Number.isFinite(raw) ? raw : 0);
+  const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
+  return `${val}${lab ? ' • ' + lab : ''}`;
+}
+
+// Stable per-instance gradient id so multiple sparklines on a page
+// don't clobber each other's <defs id="spkGrad">.
+const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
+</script>
+
+<template>
+  <svg
+    ref="svgRef"
+    width="100%"
+    :height="height"
+    :viewBox="viewBoxAttr"
+    preserveAspectRatio="none"
+    class="sparkline-svg"
+    @mousemove="onMouseMove"
+    @mouseleave="onMouseLeave"
+  >
+    <defs>
+      <linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
+        <stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
+        <stop offset="100%" :stop-color="stroke" stop-opacity="0" />
+      </linearGradient>
+    </defs>
+
+    <g v-if="showGrid">
+      <line
+        v-for="(g, i) in gridLines"
+        :key="i"
+        :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2"
+        :stroke="gridColor" stroke-width="1"
+        class="cpu-grid-line"
+      />
+    </g>
+
+    <g v-if="showAxes">
+      <text
+        v-for="(t, i) in yTicks"
+        :key="'y' + i"
+        class="cpu-grid-y-text"
+        :x="Math.max(0, paddingLeft - 4)"
+        :y="t.y + 4"
+        text-anchor="end"
+        font-size="10"
+      >{{ t.label }}</text>
+      <text
+        v-for="(t, i) in xTicks"
+        :key="'x' + i"
+        class="cpu-grid-x-text"
+        :x="t.x"
+        :y="paddingTop + drawHeight + 14"
+        text-anchor="middle"
+        font-size="10"
+      >{{ t.label }}</text>
+    </g>
+
+    <path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
+    <polyline
+      :points="pointsStr"
+      fill="none"
+      :stroke="stroke"
+      :stroke-width="strokeWidth"
+      stroke-linecap="round"
+      stroke-linejoin="round"
+    />
+    <circle
+      v-if="showMarker && lastPoint"
+      :cx="lastPoint[0]" :cy="lastPoint[1]"
+      :r="markerRadius"
+      :fill="stroke"
+    />
+
+    <g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
+      <line
+        class="cpu-grid-h-line"
+        :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]"
+        :y1="paddingTop" :y2="paddingTop + drawHeight"
+        stroke="rgba(0,0,0,0.2)" stroke-width="1"
+      />
+      <circle
+        :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]"
+        r="3.5" :fill="stroke"
+      />
+      <text
+        class="cpu-grid-text"
+        :x="pointsArr[hoverIdx][0]"
+        :y="paddingTop + 12"
+        text-anchor="middle"
+        font-size="11"
+      >{{ fmtHoverText() }}</text>
+    </g>
+  </svg>
+</template>
+
+<style scoped>
+.sparkline-svg {
+  display: block;
+  width: 100%;
+}
+</style>
+
+<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
+     reliably hash-attribute SVG descendants, so the dark-mode overrides
+     have to live in a non-scoped block to actually take effect. The
+     numbers are also small, so the dark-theme fills run at ~85% opacity
+     for legibility (the previous 55% was washed out on navy backgrounds). -->
+<style>
+.sparkline-svg .cpu-grid-y-text,
+.sparkline-svg .cpu-grid-x-text {
+  fill: rgba(0, 0, 0, 0.65);
+}
+
+.sparkline-svg .cpu-grid-text {
+  fill: rgba(0, 0, 0, 0.88);
+}
+
+body.dark .sparkline-svg .cpu-grid-y-text,
+body.dark .sparkline-svg .cpu-grid-x-text {
+  fill: rgba(255, 255, 255, 0.85);
+}
+
+body.dark .sparkline-svg .cpu-grid-text {
+  fill: rgba(255, 255, 255, 0.95);
+}
+
+body.dark .sparkline-svg .cpu-grid-line {
+  stroke: rgba(255, 255, 255, 0.12);
+}
+
+body.dark .sparkline-svg .cpu-grid-h-line {
+  stroke: rgba(255, 255, 255, 0.35);
+}
+</style>

+ 300 - 0
frontend/src/components/TableSortable.vue

@@ -0,0 +1,300 @@
+<script>
+// Use defineComponent so we can keep the parent + child components in
+// the same file with the provide() <-> inject relationship intact.
+import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
+import { DragOutlined } from '@ant-design/icons-vue';
+
+const ROW_CLASS = 'sortable-row';
+
+// Sortable a-table — drag-to-reorder rows using Pointer Events.
+//
+// Why a custom component:
+// - Old impl set draggable: true on every row, which broke text selection
+//   in cells and let HTML5 start drags from anywhere on the row. This
+//   version only initiates drag from an explicit handle, via Pointer
+//   Events (one API for mouse + touch + pen).
+// - During drag, data-source is reordered live; the source row visually
+//   slides into the target slot. The live reorder IS the visual feedback.
+// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
+//   before so existing call sites stay unchanged.
+// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
+//   one; Escape cancels an in-flight drag.
+
+export const TableSortableTrigger = defineComponent({
+  name: 'TableSortableTrigger',
+  props: {
+    itemIndex: { type: Number, required: true },
+  },
+  setup(props) {
+    const sortable = inject('sortable', null);
+    const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
+
+    function onPointerDown(e) {
+      sortable?.startDrag?.(e, props.itemIndex);
+    }
+
+    function onKeyDown(e) {
+      const move = sortable?.moveByKeyboard;
+      if (!move) return;
+      if (e.key === 'ArrowUp') {
+        e.preventDefault();
+        move(-1, props.itemIndex);
+      } else if (e.key === 'ArrowDown') {
+        e.preventDefault();
+        move(+1, props.itemIndex);
+      }
+    }
+
+    return () => h(DragOutlined, {
+      class: 'sortable-icon',
+      role: 'button',
+      tabindex: 0,
+      'aria-label': ariaLabel.value,
+      onPointerdown: onPointerDown,
+      onKeydown: onKeyDown,
+    });
+  },
+});
+
+export default defineComponent({
+  name: 'TableSortable',
+  inheritAttrs: false,
+  props: {
+    dataSource: { type: Array, default: () => [] },
+    customRow: { type: Function, default: null },
+    rowKey: { type: [String, Function], default: null },
+    locale: {
+      type: Object,
+      default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
+    },
+  },
+  emits: ['onsort'],
+  setup(props, { emit, slots, attrs, expose }) {
+    // null when idle; while dragging:
+    //   { sourceIndex, targetIndex, pointerId, sourceKey }
+    const drag = ref(null);
+    const rootRef = ref(null);
+
+    const isDragging = computed(() => drag.value !== null);
+
+    // Resolve the row key for a record. Used to identify the source row
+    // even after data-source is reordered live during drag.
+    function keyOf(record, fallback) {
+      const rk = props.rowKey;
+      if (typeof rk === 'function') return rk(record);
+      if (typeof rk === 'string') return record?.[rk];
+      return fallback;
+    }
+
+    function attachListeners() {
+      document.addEventListener('pointermove', onPointerMove, true);
+      document.addEventListener('pointerup', onPointerUp, true);
+      document.addEventListener('pointercancel', cancelDrag, true);
+      document.addEventListener('keydown', cancelDrag, true);
+    }
+
+    function detachListeners() {
+      document.removeEventListener('pointermove', onPointerMove, true);
+      document.removeEventListener('pointerup', onPointerUp, true);
+      document.removeEventListener('pointercancel', cancelDrag, true);
+      document.removeEventListener('keydown', cancelDrag, true);
+    }
+
+    function startDrag(e, sourceIndex) {
+      // Primary button only (mouse left / first touch).
+      if (e.button != null && e.button !== 0) return;
+      e.preventDefault();
+      const record = props.dataSource?.[sourceIndex];
+      drag.value = {
+        sourceIndex,
+        targetIndex: sourceIndex,
+        pointerId: e.pointerId,
+        sourceKey: keyOf(record, sourceIndex),
+      };
+      // Capture the pointer so move/up keep firing even if the cursor
+      // leaves the icon. Try/catch — some older browsers throw on capture.
+      if (e.target?.setPointerCapture && e.pointerId != null) {
+        try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
+      }
+      attachListeners();
+    }
+
+    function onPointerMove(e) {
+      const d = drag.value;
+      if (!d) return;
+      if (d.pointerId != null && e.pointerId !== d.pointerId) return;
+      const root = rootRef.value;
+      if (!root) return;
+      const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
+      if (!rows.length) return;
+      const y = e.clientY;
+      const firstRect = rows[0].getBoundingClientRect();
+      const lastRect = rows[rows.length - 1].getBoundingClientRect();
+      let target = d.targetIndex;
+      if (y < firstRect.top) {
+        target = 0;
+      } else if (y > lastRect.bottom) {
+        target = rows.length - 1;
+      } else {
+        for (let i = 0; i < rows.length; i++) {
+          const rect = rows[i].getBoundingClientRect();
+          if (y >= rect.top && y <= rect.bottom) {
+            target = i;
+            break;
+          }
+        }
+      }
+      if (target !== d.targetIndex) {
+        drag.value = { ...d, targetIndex: target };
+      }
+    }
+
+    function onPointerUp(e) {
+      const d = drag.value;
+      if (!d) return;
+      if (d.pointerId != null && e.pointerId !== d.pointerId) return;
+      detachListeners();
+      const captured = d;
+      drag.value = null;
+      if (captured.sourceIndex !== captured.targetIndex) {
+        emit('onsort', captured.sourceIndex, captured.targetIndex);
+      }
+    }
+
+    function cancelDrag(e) {
+      // Triggered by pointercancel and keydown. For keydown only act on
+      // Escape; otherwise let the event propagate.
+      if (e?.type === 'keydown' && e.key !== 'Escape') return;
+      detachListeners();
+      drag.value = null;
+    }
+
+    function moveByKeyboard(direction, sourceIndex) {
+      const target = sourceIndex + direction;
+      if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
+      emit('onsort', sourceIndex, target);
+    }
+
+    function customRowRender(record, index) {
+      const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
+      const d = drag.value;
+      const isSource = d && keyOf(record, index) === d.sourceKey;
+      // Vue 3 customRow shape: a flat object of attrs/listeners/class —
+      // no nested props/on like Vue 2.
+      return {
+        ...parent,
+        class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
+      };
+    }
+
+    // Render-data: dataSource with the source row spliced into targetIndex.
+    // When idle the original list is returned unchanged so a-table can
+    // diff against a stable reference.
+    const records = computed(() => {
+      const d = drag.value;
+      const src = props.dataSource ?? [];
+      if (!d || d.sourceIndex === d.targetIndex) return src;
+      const list = src.slice();
+      const [item] = list.splice(d.sourceIndex, 1);
+      list.splice(d.targetIndex, 0, item);
+      return list;
+    });
+
+    expose({ startDrag, moveByKeyboard });
+
+    return {
+      rootRef, drag, isDragging, records, slots, attrs,
+      startDrag, moveByKeyboard, customRowRender,
+    };
+  },
+  // provide() needs to live at the options level so child components in
+  // the rendered subtree resolve the same instance methods.
+  provide() {
+    return {
+      sortable: {
+        startDrag: (...a) => this.startDrag(...a),
+        moveByKeyboard: (...a) => this.moveByKeyboard(...a),
+      },
+    };
+  },
+  beforeUnmount() {
+    document.removeEventListener('pointermove', this.onPointerMove, true);
+    document.removeEventListener('pointerup', this.onPointerUp, true);
+    document.removeEventListener('pointercancel', this.cancelDrag, true);
+    document.removeEventListener('keydown', this.cancelDrag, true);
+  },
+  render() {
+    // Forward every passed slot to a-table by reusing the slot fn
+    // directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
+    const tableSlots = {};
+    for (const name of Object.keys(this.slots)) {
+      tableSlots[name] = this.slots[name];
+    }
+    // Resolved at runtime so the user's app.use(Antd) registration wins;
+    // avoids importing Table directly here.
+    const ATable = resolveComponent('a-table');
+    return h(
+      'div',
+      { ref: 'rootRef' },
+      [h(
+        ATable,
+        {
+          ...this.attrs,
+          'data-source': this.records,
+          'row-key': this.rowKey,
+          customRow: this.customRowRender,
+          locale: this.locale,
+          class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
+        },
+        tableSlots,
+      )],
+    );
+  },
+});
+</script>
+
+<style>
+.sortable-icon {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: grab;
+  padding: 6px;
+  border-radius: 6px;
+  color: rgba(255, 255, 255, 0.5);
+  transition: background-color 0.15s ease, color 0.15s ease;
+  user-select: none;
+  touch-action: none;
+}
+.sortable-icon:hover {
+  color: rgba(255, 255, 255, 0.85);
+  background: rgba(255, 255, 255, 0.06);
+}
+.sortable-icon:active { cursor: grabbing; }
+.sortable-icon:focus-visible {
+  outline: 2px solid #008771;
+  outline-offset: 2px;
+}
+
+.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
+.light .sortable-icon:hover {
+  color: rgba(0, 0, 0, 0.85);
+  background: rgba(0, 0, 0, 0.05);
+}
+
+.sortable-table-dragging .sortable-source-row > td {
+  background: rgba(0, 135, 113, 0.10) !important;
+  transition: background-color 0.18s ease;
+}
+.sortable-table-dragging .sortable-source-row .routing-index,
+.sortable-table-dragging .sortable-source-row .outbound-index {
+  opacity: 0.45;
+}
+.sortable-table-dragging .sortable-row > td {
+  transition: background-color 0.18s ease;
+}
+.sortable-table-dragging,
+.sortable-table-dragging * {
+  user-select: none;
+}
+</style>

+ 67 - 0
frontend/src/components/TextModal.vue

@@ -0,0 +1,67 @@
+<script setup>
+import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import { ClipboardManager, FileManager } from '@/utils';
+
+// Read-only text modal — used to surface multi-line export blobs
+// (subscription URLs, raw inbound JSON, generated share links) the
+// way the legacy txtModal did.
+
+defineProps({
+  open: { type: Boolean, default: false },
+  title: { type: String, default: '' },
+  content: { type: String, default: '' },
+  // When set, surfaces a download button that writes `content` to a
+  // text file with this name.
+  fileName: { type: String, default: '' },
+});
+
+const emit = defineEmits(['update:open']);
+
+function close() {
+  emit('update:open', false);
+}
+
+async function copy(value) {
+  const ok = await ClipboardManager.copyText(value || '');
+  if (ok) {
+    message.success('Copied');
+    close();
+  }
+}
+
+function download(content, name) {
+  if (!name) return;
+  FileManager.downloadTextFile(content, name);
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :closable="true" @cancel="close">
+    <a-textarea
+      :value="content"
+      readonly
+      :auto-size="{ minRows: 10, maxRows: 20 }"
+      class="text-modal-content"
+    />
+    <template #footer>
+      <a-button v-if="fileName" @click="download(content, fileName)">
+        <template #icon><DownloadOutlined /></template>
+        {{ fileName }}
+      </a-button>
+      <a-button type="primary" @click="copy(content)">
+        <template #icon><CopyOutlined /></template>
+        Copy
+      </a-button>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.text-modal-content {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  overflow-y: auto;
+}
+</style>

+ 46 - 0
frontend/src/components/ThemeSwitch.vue

@@ -0,0 +1,46 @@
+<script setup>
+import { computed } from 'vue';
+import { BulbFilled, BulbOutlined } from '@ant-design/icons-vue';
+import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
+
+const BulbIcon = computed(() => (theme.isDark ? BulbFilled : BulbOutlined));
+
+function onDarkChange() {
+  pauseAnimationsUntilLeave('change-theme');
+  toggleTheme();
+}
+
+function onUltraClick() {
+  pauseAnimationsUntilLeave('change-theme-ultra');
+  toggleUltra();
+}
+</script>
+
+<template>
+  <a-menu :theme="currentTheme" mode="inline" :selected-keys="[]">
+    <a-sub-menu>
+      <template #title>
+        <span>
+          <component :is="BulbIcon" />
+          <span class="theme-label">Theme</span>
+        </span>
+      </template>
+
+      <a-menu-item id="change-theme" class="ant-menu-theme-switch">
+        <span>Dark</span>
+        <a-switch :style="{ marginLeft: '2px' }" size="small" :checked="theme.isDark" @change="onDarkChange" />
+      </a-menu-item>
+
+      <a-menu-item v-if="theme.isDark" id="change-theme-ultra" class="ant-menu-theme-switch">
+        <span>Ultra dark</span>
+        <a-checkbox :style="{ marginLeft: '2px' }" :checked="theme.isUltra" @click="onUltraClick" />
+      </a-menu-item>
+    </a-sub-menu>
+  </a-menu>
+</template>
+
+<style scoped>
+.theme-label {
+  margin-left: 8px;
+}
+</style>

+ 25 - 0
frontend/src/components/ThemeSwitchLogin.vue

@@ -0,0 +1,25 @@
+<script setup>
+import { theme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
+
+function onDarkChange() {
+  pauseAnimationsUntilLeave('change-theme');
+  toggleTheme();
+}
+
+function onUltraClick() {
+  toggleUltra();
+}
+</script>
+
+<template>
+  <a-space id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
+    <a-space direction="horizontal" size="small">
+      <a-switch size="small" :checked="theme.isDark" @change="onDarkChange" />
+      <span>Dark</span>
+    </a-space>
+    <a-space v-if="theme.isDark" direction="horizontal" size="small">
+      <a-checkbox :checked="theme.isUltra" @click="onUltraClick" />
+      <span>Ultra dark</span>
+    </a-space>
+  </a-space>
+</template>

+ 45 - 0
frontend/src/composables/useDatepicker.js

@@ -0,0 +1,45 @@
+// Module-scoped reactive ref for the panel's "Calendar Type" setting.
+// Loaded from /panel/setting/defaultSettings on first use, so any
+// component (modals, inbound forms, future pages) can read the same
+// value without prop-drilling and without re-fetching.
+//
+// useInbounds (which already reads defaultSettings for its own state)
+// calls setDatepicker() after its fetch so we don't issue a second
+// HTTP round-trip on the inbounds page.
+
+import { readonly, ref } from 'vue';
+import { HttpUtil } from '@/utils';
+
+const datepicker = ref('gregorian');
+let fetched = false;
+let pending = null;
+
+async function loadOnce() {
+  if (fetched) return;
+  if (pending) {
+    await pending;
+    return;
+  }
+  pending = (async () => {
+    try {
+      const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+      if (msg?.success) {
+        datepicker.value = msg.obj?.datepicker || 'gregorian';
+      }
+    } finally {
+      fetched = true;
+      pending = null;
+    }
+  })();
+  await pending;
+}
+
+export function setDatepicker(value) {
+  fetched = true;
+  datepicker.value = value || 'gregorian';
+}
+
+export function useDatepicker() {
+  loadOnce();
+  return { datepicker: readonly(datepicker) };
+}

+ 26 - 0
frontend/src/composables/useMediaQuery.js

@@ -0,0 +1,26 @@
+import { ref, onBeforeUnmount, onMounted } from 'vue';
+
+const MOBILE_BREAKPOINT_PX = 768;
+
+// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
+// `isMobile` ref that updates on window resize. Use inside <script setup>:
+//
+//   const { isMobile } = useMediaQuery();
+export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
+  const compute = () => window.innerWidth <= breakpoint;
+  const isMobile = ref(compute());
+
+  const onResize = () => {
+    isMobile.value = compute();
+  };
+
+  onMounted(() => {
+    window.addEventListener('resize', onResize);
+  });
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('resize', onResize);
+  });
+
+  return { isMobile };
+}

+ 42 - 0
frontend/src/composables/useNodeList.js

@@ -0,0 +1,42 @@
+// Lightweight composable that fetches the node list once on mount and
+// exposes id→name + id→online lookups. Used by the Inbounds page so it
+// can render a Node selector and a Node column without pulling the
+// full pages/nodes/useNodes.js (which polls and owns CRUD state).
+
+import { onMounted, ref, computed } from 'vue';
+import { HttpUtil } from '@/utils';
+
+export function useNodeList() {
+  const nodes = ref([]);
+  const fetched = ref(false);
+
+  async function refresh() {
+    const msg = await HttpUtil.get('/panel/api/nodes/list');
+    if (msg?.success) {
+      nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
+    }
+    fetched.value = true;
+  }
+
+  // Indexed by id for O(1) UI lookups (Node column on N-row tables).
+  const byId = computed(() => {
+    const m = new Map();
+    for (const n of nodes.value) m.set(n.id, n);
+    return m;
+  });
+
+  function nameFor(id) {
+    if (id == null) return null;
+    return byId.value.get(id)?.name || null;
+  }
+
+  function isOnline(id) {
+    if (id == null) return true;
+    const n = byId.value.get(id);
+    return n != null && n.enable && n.status === 'online';
+  }
+
+  onMounted(refresh);
+
+  return { nodes, fetched, refresh, byId, nameFor, isOnline };
+}

+ 43 - 0
frontend/src/composables/useStatus.js

@@ -0,0 +1,43 @@
+import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
+
+import { HttpUtil } from '@/utils';
+import { Status } from '@/models/status.js';
+
+const POLL_INTERVAL_MS = 2000;
+
+// Polls /panel/api/server/status and exposes a reactive Status object
+// + a `fetched` flag so consumers can show a spinner before the first
+// successful fetch.
+//
+// WebSocket integration is intentionally deferred to a later sub-phase.
+// Polling at 2s is the same fallback the legacy panel falls back to
+// when its websocket link drops, so we're shipping the proven path
+// first and adding the websocket on top later.
+export function useStatus() {
+  const status = shallowRef(new Status());
+  const fetched = ref(false);
+  let timer = null;
+
+  async function refresh() {
+    try {
+      const msg = await HttpUtil.get('/panel/api/server/status');
+      if (msg?.success) {
+        status.value = new Status(msg.obj);
+        if (!fetched.value) fetched.value = true;
+      }
+    } catch (e) {
+      console.error('Failed to get status:', e);
+    }
+  }
+
+  onMounted(() => {
+    refresh();
+    timer = window.setInterval(refresh, POLL_INTERVAL_MS);
+  });
+
+  onBeforeUnmount(() => {
+    if (timer != null) window.clearInterval(timer);
+  });
+
+  return { status, fetched, refresh };
+}

+ 128 - 0
frontend/src/composables/useTheme.js

@@ -0,0 +1,128 @@
+import { reactive, computed, watchEffect } from 'vue';
+import { theme as antdTheme } from 'ant-design-vue';
+
+// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
+// from any component to read/toggle. Boot side-effects (apply current
+// theme to <body>/<html>) run once at module load so the page is in the
+// right theme before Vue mounts.
+
+const STORAGE_DARK = 'dark-mode';
+const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
+
+function readBool(key, fallback) {
+  const raw = localStorage.getItem(key);
+  if (raw === null) return fallback;
+  return raw === 'true';
+}
+
+const isDark = readBool(STORAGE_DARK, true);
+const isUltra = readBool(STORAGE_ULTRA, false);
+
+export const theme = reactive({
+  isDark,
+  isUltra,
+});
+
+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.
+const DARK_TOKENS = {
+  colorBgBase: '#0a1426',
+  colorBgLayout: '#0a1426',
+  colorBgContainer: '#142340',
+  colorBgElevated: '#1a2c4d',
+};
+const ULTRA_DARK_TOKENS = {
+  colorBgBase: '#000',
+  colorBgLayout: '#000',
+  colorBgContainer: '#0a0a0a',
+  colorBgElevated: '#141414',
+};
+
+// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
+// + 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.
+const DARK_LAYOUT_TOKENS = {
+  colorBgHeader: '#0d1d33',
+  colorBgTrigger: '#15294a',
+  colorBgBody: '#000',
+};
+const ULTRA_DARK_LAYOUT_TOKENS = {
+  colorBgHeader: '#0a0a0a',
+  colorBgTrigger: '#141414',
+  colorBgBody: '#000',
+};
+const DARK_MENU_TOKENS = {
+  colorItemBg: '#0d1d33',
+  colorSubItemBg: '#08142a',
+  menuSubMenuBg: '#0d1d33',
+};
+const ULTRA_DARK_MENU_TOKENS = {
+  colorItemBg: '#0a0a0a',
+  colorSubItemBg: '#000',
+  menuSubMenuBg: '#0a0a0a',
+};
+
+export const antdThemeConfig = computed(() => {
+  if (!theme.isDark) {
+    return { algorithm: antdTheme.defaultAlgorithm };
+  }
+  return {
+    algorithm: antdTheme.darkAlgorithm,
+    token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
+    components: {
+      Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
+      Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
+    },
+  };
+});
+
+export function toggleTheme() {
+  theme.isDark = !theme.isDark;
+}
+
+export function toggleUltra() {
+  theme.isUltra = !theme.isUltra;
+}
+
+// Briefly disable theme transition animations while a toggle is in
+// flight, then re-enable on mouseleave. Mirrors the legacy panel's
+// behavior of preventing flicker when hovering the theme menu.
+export function pauseAnimationsUntilLeave(elementId) {
+  document.documentElement.setAttribute('data-theme-animations', 'off');
+  const el = document.getElementById(elementId);
+  if (!el) return;
+  const restore = () => {
+    document.documentElement.removeAttribute('data-theme-animations');
+    el.removeEventListener('mouseleave', restore);
+    el.removeEventListener('touchend', restore);
+  };
+  el.addEventListener('mouseleave', restore);
+  el.addEventListener('touchend', restore);
+}
+
+// Apply theme to DOM and persist whenever it changes.
+watchEffect(() => {
+  document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
+  localStorage.setItem(STORAGE_DARK, String(theme.isDark));
+
+  if (theme.isUltra) {
+    document.documentElement.setAttribute('data-theme', 'ultra-dark');
+  } else {
+    document.documentElement.removeAttribute('data-theme');
+  }
+  localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
+
+  // Keep the global #message container's class in sync so AD-Vue toasts
+  // pick up the right styling.
+  const msg = document.getElementById('message');
+  if (msg) msg.className = theme.isDark ? 'dark' : 'light';
+});

+ 48 - 0
frontend/src/composables/useWebSocket.js

@@ -0,0 +1,48 @@
+import { onBeforeUnmount, onMounted } from 'vue';
+import { WebSocketClient } from '@/api/websocket.js';
+
+// One client per browser tab (= per multi-page entry). WebSocketClient is
+// idempotent: repeated connect() calls while the socket is already open
+// are no-ops, so multiple components on the same page can share a single
+// underlying connection without each spawning their own.
+let sharedClient = null;
+
+function getSharedClient() {
+  if (sharedClient) return sharedClient;
+  const basePath = (typeof window !== 'undefined' && window.__X_UI_BASE_PATH__) || '';
+  sharedClient = new WebSocketClient(basePath);
+  return sharedClient;
+}
+
+// useWebSocket lets a Vue component subscribe to live server-pushed
+// events. Pass a map of { eventName: handler } and the composable wires
+// connect()/disconnect() into the component lifecycle and unsubscribes
+// every handler on unmount so a stale closure can't fire after the
+// page has moved on.
+//
+// Example:
+//   useWebSocket({
+//     traffic: (payload) => applyTrafficEvent(payload),
+//     client_stats: (payload) => applyClientStatsEvent(payload),
+//     invalidate: ({ dataType }) => { if (dataType === 'inbounds') refresh(); },
+//   });
+//
+// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
+// can be subscribed to alongside server-emitted types.
+export function useWebSocket(handlers) {
+  const client = getSharedClient();
+  const entries = Object.entries(handlers || {});
+
+  onMounted(() => {
+    for (const [event, fn] of entries) client.on(event, fn);
+    client.connect();
+  });
+
+  onBeforeUnmount(() => {
+    for (const [event, fn] of entries) client.off(event, fn);
+    // Don't disconnect — another mounted component on the same page may
+    // still be subscribed. The client closes naturally on page unload.
+  });
+
+  return { client };
+}

+ 17 - 0
frontend/src/entries/inbounds.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(InboundsPage).use(Antd).use(i18n).mount('#app');

+ 19 - 0
frontend/src/entries/index.js

@@ -0,0 +1,19 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+// Importing useTheme triggers the boot side-effect that applies the
+// stored theme to <body>/<html> before Vue mounts.
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import IndexPage from '@/pages/index/IndexPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(IndexPage).use(Antd).use(i18n).mount('#app');

+ 21 - 0
frontend/src/entries/login.js

@@ -0,0 +1,21 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+// Importing this module triggers the boot side-effect that applies the
+// stored theme to <body>/<html> before Vue renders anything.
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import LoginPage from '@/pages/login/LoginPage.vue';
+
+setupAxios();
+
+// Toasts attach to a #message div the page provides — keeps theme
+// styling in sync with the rest of the panel.
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(LoginPage).use(Antd).use(i18n).mount('#app');

+ 17 - 0
frontend/src/entries/nodes.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import NodesPage from '@/pages/nodes/NodesPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(NodesPage).use(Antd).use(i18n).mount('#app');

+ 19 - 0
frontend/src/entries/settings.js

@@ -0,0 +1,19 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+// Importing useTheme triggers the boot side-effect that applies the
+// stored theme to <body>/<html> before Vue mounts.
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import SettingsPage from '@/pages/settings/SettingsPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(SettingsPage).use(Antd).use(i18n).mount('#app');

+ 18 - 0
frontend/src/entries/subpage.js

@@ -0,0 +1,18 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+// The sub page is served by the subscription HTTP server (sub/sub.go)
+// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
+// with the parsed traffic/quota/expiry view-model and the rendered
+// share links — the SPA reads those at mount.
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import SubPage from '@/pages/sub/SubPage.vue';
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(SubPage).use(Antd).use(i18n).mount('#app');

+ 17 - 0
frontend/src/entries/xray.js

@@ -0,0 +1,17 @@
+import { createApp } from 'vue';
+import Antd, { message } from 'ant-design-vue';
+import 'ant-design-vue/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import '@/composables/useTheme.js';
+import { i18n } from '@/i18n/index.js';
+import XrayPage from '@/pages/xray/XrayPage.vue';
+
+setupAxios();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+createApp(XrayPage).use(Antd).use(i18n).mount('#app');

+ 93 - 0
frontend/src/i18n/index.js

@@ -0,0 +1,93 @@
+// vue-i18n setup. Locale files live in web/translation/*.json — the same
+// directory the Go binary embeds, so SPA + Telegram bot + subscription
+// page all read from a single source.
+//
+// Usage in a component:
+//   import { useI18n } from 'vue-i18n';
+//   const { t } = useI18n();
+//   ...
+//   <span>{{ t('pages.inbounds.email') }}</span>
+//
+// Or via the global helper exposed on the app:
+//   <span>{{ $t('pages.inbounds.email') }}</span>
+//
+// The locale follows the `lang` cookie that LanguageManager already
+// reads/writes — switching language anywhere in the app continues to
+// trigger a full page reload (matches legacy ergonomics), so we don't
+// need a runtime locale switcher here.
+
+import { createI18n } from 'vue-i18n';
+
+import { LanguageManager } from '@/utils';
+
+// Lazy-loaded locales — Vite splits each one into its own chunk. We
+// eager-load only the active language plus the en-US fallback so the
+// initial page payload stays small (the inbounds bundle was sitting
+// at ~700kB gzipped with all 13 locales eager; now ~480kB).
+//
+// LanguageManager.setLanguage() does a full reload on change, so
+// "lazy" here effectively means "load only what this page needs for
+// its lifetime."
+const FALLBACK = 'en-US';
+const lazyModules = import.meta.glob('../../../web/translation/*.json');
+const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
+
+function moduleKeyFor(code) {
+  return `../../../web/translation/${code}.json`;
+}
+
+// Resolve the active locale via LanguageManager so the cookie set on
+// the legacy panel keeps working after a user upgrades. Falls back
+// to en-US when the cookie names a language we don't have.
+let active = LanguageManager.getLanguage();
+if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+  active = FALLBACK;
+}
+
+const messages = {};
+// Eagerly include the active locale + the fallback (when distinct)
+// so the very first render has strings ready. Vite still emits these
+// as their own chunks so the user pays for at most two locales.
+for (const code of new Set([active, FALLBACK])) {
+  const mod = eagerModules[moduleKeyFor(code)];
+  if (mod) messages[code] = mod.default || mod;
+}
+
+export const i18n = createI18n({
+  legacy: false,
+  // `composition` mode (legacy: false) so `useI18n()` works in
+  // <script setup> blocks.
+  globalInjection: true,
+  locale: active,
+  fallbackLocale: FALLBACK,
+  // Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
+  // so vue-i18n's default `.`-delimited lookups walk straight into it.
+  messages,
+  // The Go side sometimes interpolates `#variable#` into translated
+  // strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
+  // expects `{var}` — disable warnings about strings that look like
+  // they don't use the new syntax.
+  warnHtmlMessage: false,
+  missingWarn: false,
+  fallbackWarn: false,
+});
+
+// Convenience export for non-component contexts (HTTP error toasts,
+// stores, etc.) that need to look up a translation outside a setup
+// scope.
+export function t(key, params) {
+  return i18n.global.t(key, params || {});
+}
+
+// loadLocale fetches a locale module on demand and registers it with
+// vue-i18n. Pages that switch language at runtime (rather than via
+// LanguageManager's reload) can call this to swap strings live.
+export async function loadLocale(code) {
+  const key = moduleKeyFor(code);
+  const loader = lazyModules[key];
+  if (!loader) return false;
+  const mod = await loader();
+  i18n.global.setLocaleMessage(code, mod.default || mod);
+  i18n.global.locale.value = code;
+  return true;
+}

+ 11 - 4
web/assets/js/model/dbinbound.js → frontend/src/models/dbinbound.js

@@ -1,4 +1,8 @@
-class DBInbound {
+import dayjs from 'dayjs';
+import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
+import { Inbound, Protocols } from './inbound.js';
+
+export class DBInbound {
 
     constructor(data) {
         this.id = 0;
@@ -21,6 +25,9 @@ class DBInbound {
         this.tag = "";
         this.sniffing = "";
         this.clientStats = ""
+        // Optional FK to web/runtime registered Node. null/undefined =
+        // local panel; otherwise the inbound lives on the named node.
+        this.nodeId = null;
         if (data == null) {
             return;
         }
@@ -75,7 +82,7 @@ class DBInbound {
         if (this.expiryTime === 0) {
             return null;
         }
-        return moment(this.expiryTime);
+        return dayjs(this.expiryTime);
     }
 
     set _expiryTime(t) {
@@ -169,8 +176,8 @@ class DBInbound {
         }
     }
 
-    genInboundLinks(remarkModel) {
+    genInboundLinks(remarkModel, hostOverride = '') {
         const inbound = this.toInbound();
-        return inbound.genInboundLinks(this.remark, remarkModel);
+        return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
     }
 }

+ 58 - 43
web/assets/js/model/inbound.js → frontend/src/models/inbound.js

@@ -1,4 +1,7 @@
-const Protocols = {
+import dayjs from 'dayjs';
+import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils';
+
+export const Protocols = {
     VMESS: 'vmess',
     VLESS: 'vless',
     TROJAN: 'trojan',
@@ -11,7 +14,7 @@ const Protocols = {
     TUN: 'tun',
 };
 
-const SSMethods = {
+export const SSMethods = {
     CHACHA20_POLY1305: 'chacha20-poly1305',
     CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
     XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
@@ -20,19 +23,19 @@ const SSMethods = {
     BLAKE3_CHACHA20_POLY1305: '2022-blake3-chacha20-poly1305',
 };
 
-const TLS_FLOW_CONTROL = {
+export const TLS_FLOW_CONTROL = {
     VISION: "xtls-rprx-vision",
     VISION_UDP443: "xtls-rprx-vision-udp443",
 };
 
-const TLS_VERSION_OPTION = {
+export const TLS_VERSION_OPTION = {
     TLS10: "1.0",
     TLS11: "1.1",
     TLS12: "1.2",
     TLS13: "1.3",
 };
 
-const TLS_CIPHER_OPTION = {
+export const TLS_CIPHER_OPTION = {
     AES_128_GCM: "TLS_AES_128_GCM_SHA256",
     AES_256_GCM: "TLS_AES_256_GCM_SHA384",
     CHACHA20_POLY1305: "TLS_CHACHA20_POLY1305_SHA256",
@@ -48,7 +51,7 @@ const TLS_CIPHER_OPTION = {
     ECDHE_RSA_CHACHA20_POLY1305: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
 };
 
-const UTLS_FINGERPRINT = {
+export const UTLS_FINGERPRINT = {
     UTLS_CHROME: "chrome",
     UTLS_FIREFOX: "firefox",
     UTLS_SAFARI: "safari",
@@ -63,26 +66,26 @@ const UTLS_FINGERPRINT = {
     UTLS_UNSAFE: "unsafe",
 };
 
-const ALPN_OPTION = {
+export const ALPN_OPTION = {
     H3: "h3",
     H2: "h2",
     HTTP1: "http/1.1",
 };
 
-const SNIFFING_OPTION = {
+export const SNIFFING_OPTION = {
     HTTP: "http",
     TLS: "tls",
     QUIC: "quic",
     FAKEDNS: "fakedns"
 };
 
-const USAGE_OPTION = {
+export const USAGE_OPTION = {
     ENCIPHERMENT: "encipherment",
     VERIFY: "verify",
     ISSUE: "issue",
 };
 
-const DOMAIN_STRATEGY_OPTION = {
+export const DOMAIN_STRATEGY_OPTION = {
     AS_IS: "AsIs",
     USE_IP: "UseIP",
     USE_IPV6V4: "UseIPv6v4",
@@ -96,13 +99,13 @@ const DOMAIN_STRATEGY_OPTION = {
     FORCE_IPV4: "ForceIPv4",
 };
 
-const TCP_CONGESTION_OPTION = {
+export const TCP_CONGESTION_OPTION = {
     BBR: "bbr",
     CUBIC: "cubic",
     RENO: "reno",
 };
 
-const USERS_SECURITY = {
+export const USERS_SECURITY = {
     AES_128_GCM: "aes-128-gcm",
     CHACHA20_POLY1305: "chacha20-poly1305",
     AUTO: "auto",
@@ -110,7 +113,7 @@ const USERS_SECURITY = {
     ZERO: "zero",
 };
 
-const MODE_OPTION = {
+export const MODE_OPTION = {
     AUTO: "auto",
     PACKET_UP: "packet-up",
     STREAM_UP: "stream-up",
@@ -131,7 +134,7 @@ Object.freeze(TCP_CONGESTION_OPTION);
 Object.freeze(USERS_SECURITY);
 Object.freeze(MODE_OPTION);
 
-class XrayCommonClass {
+export class XrayCommonClass {
 
     static toJsonArray(arr) {
         return arr.map(obj => obj.toJson());
@@ -201,7 +204,7 @@ class XrayCommonClass {
     }
 }
 
-class TcpStreamSettings extends XrayCommonClass {
+export class TcpStreamSettings extends XrayCommonClass {
     constructor(
         acceptProxyProtocol = false,
         type = 'none',
@@ -329,7 +332,7 @@ TcpStreamSettings.TcpResponse = class extends XrayCommonClass {
     }
 };
 
-class KcpStreamSettings extends XrayCommonClass {
+export class KcpStreamSettings extends XrayCommonClass {
     constructor(
         mtu = 1350,
         tti = 20,
@@ -370,7 +373,7 @@ class KcpStreamSettings extends XrayCommonClass {
     }
 }
 
-class WsStreamSettings extends XrayCommonClass {
+export class WsStreamSettings extends XrayCommonClass {
     constructor(
         acceptProxyProtocol = false,
         path = '/',
@@ -415,7 +418,7 @@ class WsStreamSettings extends XrayCommonClass {
     }
 }
 
-class GrpcStreamSettings extends XrayCommonClass {
+export class GrpcStreamSettings extends XrayCommonClass {
     constructor(
         serviceName = "",
         authority = "",
@@ -444,7 +447,7 @@ class GrpcStreamSettings extends XrayCommonClass {
     }
 }
 
-class HTTPUpgradeStreamSettings extends XrayCommonClass {
+export class HTTPUpgradeStreamSettings extends XrayCommonClass {
     constructor(
         acceptProxyProtocol = false,
         path = '/',
@@ -496,7 +499,7 @@ class HTTPUpgradeStreamSettings extends XrayCommonClass {
 // doesn't read it) but we keep it here so the admin can set request
 // headers that get embedded into the share link's `extra` blob — the
 // client picks them up from there.
-class xHTTPStreamSettings extends XrayCommonClass {
+export class xHTTPStreamSettings extends XrayCommonClass {
     constructor(
         // Bidirectional — must match between client and server
         path = '/',
@@ -609,7 +612,7 @@ class xHTTPStreamSettings extends XrayCommonClass {
     }
 }
 
-class HysteriaStreamSettings extends XrayCommonClass {
+export class HysteriaStreamSettings extends XrayCommonClass {
     constructor(
         protocol,
         version = 2,
@@ -653,7 +656,7 @@ class HysteriaStreamSettings extends XrayCommonClass {
     }
 };
 
-class HysteriaMasquerade extends XrayCommonClass {
+export class HysteriaMasquerade extends XrayCommonClass {
     constructor(
         type = 'proxy',
         dir = '',
@@ -709,7 +712,7 @@ class HysteriaMasquerade extends XrayCommonClass {
         };
     }
 };
-class TlsStreamSettings extends XrayCommonClass {
+export class TlsStreamSettings extends XrayCommonClass {
     constructor(
         serverName = '',
         minVersion = TLS_VERSION_OPTION.TLS12,
@@ -876,7 +879,7 @@ TlsStreamSettings.Settings = class extends XrayCommonClass {
 };
 
 
-class RealityStreamSettings extends XrayCommonClass {
+export class RealityStreamSettings extends XrayCommonClass {
     constructor(
         show = false,
         xver = 0,
@@ -990,7 +993,7 @@ RealityStreamSettings.Settings = class extends XrayCommonClass {
     }
 };
 
-class SockoptStreamSettings extends XrayCommonClass {
+export class SockoptStreamSettings extends XrayCommonClass {
     constructor(
         acceptProxyProtocol = false,
         tcpFastOpen = false,
@@ -1079,7 +1082,7 @@ class SockoptStreamSettings extends XrayCommonClass {
     }
 }
 
-class UdpMask extends XrayCommonClass {
+export class UdpMask extends XrayCommonClass {
     constructor(type = 'salamander', settings = {}) {
         super();
         this.type = type;
@@ -1156,7 +1159,7 @@ class UdpMask extends XrayCommonClass {
     }
 }
 
-class TcpMask extends XrayCommonClass {
+export class TcpMask extends XrayCommonClass {
     constructor(type = 'fragment', settings = {}) {
         super();
         this.type = type;
@@ -1227,7 +1230,7 @@ class TcpMask extends XrayCommonClass {
     }
 }
 
-class QuicParams extends XrayCommonClass {
+export class QuicParams extends XrayCommonClass {
     constructor(
         congestion = 'bbr',
         debug = false,
@@ -1306,7 +1309,7 @@ class QuicParams extends XrayCommonClass {
     }
 }
 
-class FinalMaskStreamSettings extends XrayCommonClass {
+export class FinalMaskStreamSettings extends XrayCommonClass {
     constructor(tcp = [], udp = [], quicParams = undefined) {
         super();
         this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
@@ -1345,7 +1348,7 @@ class FinalMaskStreamSettings extends XrayCommonClass {
     }
 }
 
-class StreamSettings extends XrayCommonClass {
+export class StreamSettings extends XrayCommonClass {
     constructor(network = 'tcp',
         security = 'none',
         externalProxy = [],
@@ -1478,7 +1481,7 @@ class StreamSettings extends XrayCommonClass {
     }
 }
 
-class Sniffing extends XrayCommonClass {
+export class Sniffing extends XrayCommonClass {
     constructor(
         enabled = false,
         destOverride = ['http', 'tls', 'quic', 'fakedns'],
@@ -1522,7 +1525,7 @@ class Sniffing extends XrayCommonClass {
     }
 }
 
-class Inbound extends XrayCommonClass {
+export class Inbound extends XrayCommonClass {
     constructor(
         port = RandomUtil.randomInteger(10000, 60000),
         listen = '',
@@ -2293,8 +2296,20 @@ class Inbound extends XrayCommonClass {
         return url.toString();
     }
 
-    genWireguardLinks(remark = '', remarkModel = '-ieo') {
-        const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+    // resolveAddr picks the host that goes into share/sub links. Order:
+    //   1. hostOverride (caller supplies node address for node-managed inbounds)
+    //   2. inbound's bind listen (when explicit, not 0.0.0.0)
+    //   3. browser's location.hostname (single-panel default)
+    // Centralised so genAllLinks/genInboundLinks/genWireguard*
+    // all share the same chain — pre-Phase 3 we had four duplicated lines.
+    _resolveAddr(hostOverride = '') {
+        if (hostOverride) return hostOverride;
+        if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") return this.listen;
+        return location.hostname;
+    }
+
+    genWireguardLinks(remark = '', remarkModel = '-ieo', hostOverride = '') {
+        const addr = this._resolveAddr(hostOverride);
         const separationChar = remarkModel.charAt(0);
         let links = [];
         this.settings.peers.forEach((p, index) => {
@@ -2303,8 +2318,8 @@ class Inbound extends XrayCommonClass {
         return links.join('\r\n');
     }
 
-    genWireguardConfigs(remark = '', remarkModel = '-ieo') {
-        const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+    genWireguardConfigs(remark = '', remarkModel = '-ieo', hostOverride = '') {
+        const addr = this._resolveAddr(hostOverride);
         const separationChar = remarkModel.charAt(0);
         let links = [];
         this.settings.peers.forEach((p, index) => {
@@ -2329,10 +2344,10 @@ class Inbound extends XrayCommonClass {
         }
     }
 
-    genAllLinks(remark = '', remarkModel = '-ieo', client) {
+    genAllLinks(remark = '', remarkModel = '-ieo', client, hostOverride = '') {
         let result = [];
         let email = client ? client.email : '';
-        let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+        let addr = this._resolveAddr(hostOverride);
         let port = this.port;
         const separationChar = remarkModel.charAt(0);
         const orderChars = remarkModel.slice(1);
@@ -2360,12 +2375,12 @@ class Inbound extends XrayCommonClass {
         return result;
     }
 
-    genInboundLinks(remark = '', remarkModel = '-ieo') {
-        let addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+    genInboundLinks(remark = '', remarkModel = '-ieo', hostOverride = '') {
+        let addr = this._resolveAddr(hostOverride);
         if (this.clients) {
             let links = [];
             this.clients.forEach((client) => {
-                this.genAllLinks(remark, remarkModel, client).forEach(l => {
+                this.genAllLinks(remark, remarkModel, client, hostOverride).forEach(l => {
                     links.push(l.link);
                 })
             });
@@ -2373,7 +2388,7 @@ class Inbound extends XrayCommonClass {
         } else {
             if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
             if (this.protocol == Protocols.WIREGUARD) {
-                return this.genWireguardConfigs(remark, remarkModel);
+                return this.genWireguardConfigs(remark, remarkModel, hostOverride);
             }
             return '';
         }
@@ -2521,7 +2536,7 @@ Inbound.ClientBase = class extends XrayCommonClass {
         if (this.expiryTime < 0) {
             return this.expiryTime / -86400000;
         }
-        return moment(this.expiryTime);
+        return dayjs(this.expiryTime);
     }
 
     set _expiryTime(t) {

+ 39 - 37
web/assets/js/model/outbound.js → frontend/src/models/outbound.js

@@ -1,4 +1,6 @@
-const Protocols = {
+import { ObjectUtil, Base64, Wireguard } from '@/utils';
+
+export const Protocols = {
     Freedom: "freedom",
     Blackhole: "blackhole",
     DNS: "dns",
@@ -12,7 +14,7 @@ const Protocols = {
     HTTP: "http",
 };
 
-const SSMethods = {
+export const SSMethods = {
     AES_256_GCM: 'aes-256-gcm',
     AES_128_GCM: 'aes-128-gcm',
     CHACHA20_POLY1305: 'chacha20-poly1305',
@@ -24,12 +26,12 @@ const SSMethods = {
     BLAKE3_CHACHA20_POLY1305: '2022-blake3-chacha20-poly1305',
 };
 
-const TLS_FLOW_CONTROL = {
+export const TLS_FLOW_CONTROL = {
     VISION: "xtls-rprx-vision",
     VISION_UDP443: "xtls-rprx-vision-udp443",
 };
 
-const UTLS_FINGERPRINT = {
+export const UTLS_FINGERPRINT = {
     UTLS_CHROME: "chrome",
     UTLS_FIREFOX: "firefox",
     UTLS_SAFARI: "safari",
@@ -44,20 +46,20 @@ const UTLS_FINGERPRINT = {
     UTLS_UNSAFE: "unsafe",
 };
 
-const ALPN_OPTION = {
+export const ALPN_OPTION = {
     H3: "h3",
     H2: "h2",
     HTTP1: "http/1.1",
 };
 
-const SNIFFING_OPTION = {
+export const SNIFFING_OPTION = {
     HTTP: "http",
     TLS: "tls",
     QUIC: "quic",
     FAKEDNS: "fakedns"
 };
 
-const OutboundDomainStrategies = [
+export const OutboundDomainStrategies = [
     "AsIs",
     "UseIP",
     "UseIPv4",
@@ -71,7 +73,7 @@ const OutboundDomainStrategies = [
     "ForceIPv4"
 ];
 
-const WireguardDomainStrategy = [
+export const WireguardDomainStrategy = [
     "ForceIP",
     "ForceIPv4",
     "ForceIPv4v6",
@@ -79,7 +81,7 @@ const WireguardDomainStrategy = [
     "ForceIPv6v4"
 ];
 
-const USERS_SECURITY = {
+export const USERS_SECURITY = {
     AES_128_GCM: "aes-128-gcm",
     CHACHA20_POLY1305: "chacha20-poly1305",
     AUTO: "auto",
@@ -87,14 +89,14 @@ const USERS_SECURITY = {
     ZERO: "zero",
 };
 
-const MODE_OPTION = {
+export const MODE_OPTION = {
     AUTO: "auto",
     PACKET_UP: "packet-up",
     STREAM_UP: "stream-up",
     STREAM_ONE: "stream-one",
 };
 
-const Address_Port_Strategy = {
+export const Address_Port_Strategy = {
     NONE: "none",
     SrvPortOnly: "srvportonly",
     SrvAddressOnly: "srvaddressonly",
@@ -104,9 +106,9 @@ const Address_Port_Strategy = {
     TxtPortAndAddress: "txtportandaddress"
 };
 
-const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
+export const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
 
-function normalizeDNSRuleField(value) {
+export function normalizeDNSRuleField(value) {
     if (value === null || value === undefined) {
         return '';
     }
@@ -116,12 +118,12 @@ function normalizeDNSRuleField(value) {
     return value.toString().trim();
 }
 
-function normalizeDNSRuleAction(action) {
+export function normalizeDNSRuleAction(action) {
     action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
     return DNSRuleActions.includes(action) ? action : 'direct';
 }
 
-function parseLegacyDNSBlockTypes(blockTypes) {
+export function parseLegacyDNSBlockTypes(blockTypes) {
     if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
         return [];
     }
@@ -145,7 +147,7 @@ function parseLegacyDNSBlockTypes(blockTypes) {
         .filter(item => item >= 0 && item <= 65535);
 }
 
-function buildLegacyDNSRules(nonIPQuery, blockTypes) {
+export function buildLegacyDNSRules(nonIPQuery, blockTypes) {
     const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
     const rules = [];
     const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
@@ -160,7 +162,7 @@ function buildLegacyDNSRules(nonIPQuery, blockTypes) {
     return rules;
 }
 
-function getDNSRulesFromJson(json = {}) {
+export function getDNSRulesFromJson(json = {}) {
     if (Array.isArray(json.rules) && json.rules.length > 0) {
         return json.rules.map(rule => Outbound.DNSRule.fromJson(rule));
     }
@@ -185,7 +187,7 @@ Object.freeze(MODE_OPTION);
 Object.freeze(Address_Port_Strategy);
 Object.freeze(DNSRuleActions);
 
-class CommonClass {
+export class CommonClass {
 
     static toJsonArray(arr) {
         return arr.map(obj => obj.toJson());
@@ -204,7 +206,7 @@ class CommonClass {
     }
 }
 
-class ReverseSniffing extends CommonClass {
+export class ReverseSniffing extends CommonClass {
     constructor(
         enabled = false,
         destOverride = ['http', 'tls', 'quic', 'fakedns'],
@@ -248,7 +250,7 @@ class ReverseSniffing extends CommonClass {
     }
 }
 
-class TcpStreamSettings extends CommonClass {
+export class TcpStreamSettings extends CommonClass {
     constructor(type = 'none', host, path) {
         super();
         this.type = type;
@@ -284,7 +286,7 @@ class TcpStreamSettings extends CommonClass {
     }
 }
 
-class KcpStreamSettings extends CommonClass {
+export class KcpStreamSettings extends CommonClass {
     constructor(
         mtu = 1350,
         tti = 20,
@@ -325,7 +327,7 @@ class KcpStreamSettings extends CommonClass {
     }
 }
 
-class WsStreamSettings extends CommonClass {
+export class WsStreamSettings extends CommonClass {
     constructor(
         path = '/',
         host = '',
@@ -355,7 +357,7 @@ class WsStreamSettings extends CommonClass {
     }
 }
 
-class GrpcStreamSettings extends CommonClass {
+export class GrpcStreamSettings extends CommonClass {
     constructor(
         serviceName = "",
         authority = "",
@@ -380,7 +382,7 @@ class GrpcStreamSettings extends CommonClass {
     }
 }
 
-class HttpUpgradeStreamSettings extends CommonClass {
+export class HttpUpgradeStreamSettings extends CommonClass {
     constructor(path = '/', host = '') {
         super();
         this.path = path;
@@ -408,7 +410,7 @@ class HttpUpgradeStreamSettings extends CommonClass {
 // against the server, live here. Server-only fields (noSSEHeader,
 // scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes) belong
 // on the inbound class instead.
-class xHTTPStreamSettings extends CommonClass {
+export class xHTTPStreamSettings extends CommonClass {
     constructor(
         // Bidirectional — must match the inbound side
         path = '/',
@@ -561,7 +563,7 @@ class xHTTPStreamSettings extends CommonClass {
     }
 }
 
-class TlsStreamSettings extends CommonClass {
+export class TlsStreamSettings extends CommonClass {
     constructor(
         serverName = '',
         alpn = [],
@@ -602,7 +604,7 @@ class TlsStreamSettings extends CommonClass {
     }
 }
 
-class RealityStreamSettings extends CommonClass {
+export class RealityStreamSettings extends CommonClass {
     constructor(
         publicKey = '',
         fingerprint = '',
@@ -641,7 +643,7 @@ class RealityStreamSettings extends CommonClass {
     }
 };
 
-class HysteriaStreamSettings extends CommonClass {
+export class HysteriaStreamSettings extends CommonClass {
     constructor(
         version = 2,
         auth = '',
@@ -736,7 +738,7 @@ class HysteriaStreamSettings extends CommonClass {
         return result;
     }
 };
-class SockoptStreamSettings extends CommonClass {
+export class SockoptStreamSettings extends CommonClass {
     constructor(
         dialerProxy = "",
         tcpFastOpen = false,
@@ -785,7 +787,7 @@ class SockoptStreamSettings extends CommonClass {
     }
 }
 
-class UdpMask extends CommonClass {
+export class UdpMask extends CommonClass {
     constructor(type = 'salamander', settings = {}) {
         super();
         this.type = type;
@@ -870,7 +872,7 @@ class UdpMask extends CommonClass {
     }
 }
 
-class TcpMask extends CommonClass {
+export class TcpMask extends CommonClass {
     constructor(type = 'fragment', settings = {}) {
         super();
         this.type = type;
@@ -941,7 +943,7 @@ class TcpMask extends CommonClass {
     }
 }
 
-class QuicParams extends CommonClass {
+export class QuicParams extends CommonClass {
     constructor(
         congestion = 'bbr',
         debug = false,
@@ -1020,7 +1022,7 @@ class QuicParams extends CommonClass {
     }
 }
 
-class FinalMaskStreamSettings extends CommonClass {
+export class FinalMaskStreamSettings extends CommonClass {
     constructor(tcp = [], udp = [], quicParams = undefined) {
         super();
         this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
@@ -1059,7 +1061,7 @@ class FinalMaskStreamSettings extends CommonClass {
     }
 }
 
-class StreamSettings extends CommonClass {
+export class StreamSettings extends CommonClass {
     constructor(
         network = 'tcp',
         security = 'none',
@@ -1172,7 +1174,7 @@ class StreamSettings extends CommonClass {
     }
 }
 
-class Mux extends CommonClass {
+export class Mux extends CommonClass {
     constructor(enabled = false, concurrency = 8, xudpConcurrency = 16, xudpProxyUDP443 = "reject") {
         super();
         this.enabled = enabled;
@@ -1201,7 +1203,7 @@ class Mux extends CommonClass {
     }
 }
 
-class Outbound extends CommonClass {
+export class Outbound extends CommonClass {
     constructor(
         tag = '',
         protocol = Protocols.VLESS,
@@ -1336,7 +1338,7 @@ class Outbound extends CommonClass {
     }
 
     static fromLink(link) {
-        data = link.split('://');
+        const data = link.split('://');
         if (data.length != 2) return null;
         switch (data[0].toLowerCase()) {
             case Protocols.VMess:

+ 24 - 24
web/assets/js/model/reality_targets.js → frontend/src/models/reality-targets.js

@@ -1,24 +1,24 @@
-// List of popular services for VLESS Reality Target/SNI randomization
-const REALITY_TARGETS = [
-    { target: 'www.amazon.com:443', sni: 'www.amazon.com' },
-    { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
-    { target: 'www.oracle.com:443', sni: 'www.oracle.com' },
-    { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
-    { target: 'www.amd.com:443', sni: 'www.amd.com' },
-    { target: 'www.intel.com:443', sni: 'www.intel.com' },
-    { target: 'www.sony.com:443', sni: 'www.sony.com' }
-];
-
-/**
- * Returns a random Reality target configuration from the predefined list
- * @returns {Object} Object with target and sni properties
- */
-function getRandomRealityTarget() {
-    const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
-    const selected = REALITY_TARGETS[randomIndex];
-    // Return a copy to avoid reference issues
-    return {
-        target: selected.target,
-        sni: selected.sni
-    };
-}
+// List of popular services for VLESS Reality Target/SNI randomization
+export const REALITY_TARGETS = [
+    { target: 'www.amazon.com:443', sni: 'www.amazon.com' },
+    { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
+    { target: 'www.oracle.com:443', sni: 'www.oracle.com' },
+    { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
+    { target: 'www.amd.com:443', sni: 'www.amd.com' },
+    { target: 'www.intel.com:443', sni: 'www.intel.com' },
+    { target: 'www.sony.com:443', sni: 'www.sony.com' }
+];
+
+/**
+ * Returns a random Reality target configuration from the predefined list
+ * @returns {Object} Object with target and sni properties
+ */
+export function getRandomRealityTarget() {
+    const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
+    const selected = REALITY_TARGETS[randomIndex];
+    // Return a copy to avoid reference issues
+    return {
+        target: selected.target,
+        sni: selected.sni
+    };
+}

+ 8 - 1
web/assets/js/model/setting.js → frontend/src/models/setting.js

@@ -1,4 +1,11 @@
-class AllSetting {
+// Mirrors web/assets/js/model/setting.js — every field on this class is
+// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
+// so adding a field here without a matching Go-side change will silently
+// drop it on save. Defaults match the legacy panel.
+
+import { ObjectUtil } from '@/utils';
+
+export class AllSetting {
 
     constructor(data) {
         this.webListen = "";

+ 78 - 0
frontend/src/models/status.js

@@ -0,0 +1,78 @@
+import { NumberFormatter } from '@/utils';
+
+export class CurTotal {
+  constructor(current, total) {
+    this.current = current;
+    this.total = total;
+  }
+
+  get percent() {
+    if (this.total === 0) return 0;
+    return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
+  }
+
+  get color() {
+    // Match AD-Vue 4's semantic palette so the gauges fit the
+    // global blue/gold/red theme instead of the legacy teal/orange.
+    const p = this.percent;
+    if (p < 80) return '#1677ff'; // primary
+    if (p < 90) return '#faad14'; // warning
+    return '#ff4d4f';             // danger
+  }
+}
+
+const XRAY_STATE_COLORS = {
+  running: 'green',
+  stop: 'orange',
+  error: 'red',
+};
+
+const XRAY_STATE_MESSAGES = {
+  running: 'Xray is running',
+  stop: 'Xray is stopped',
+  error: 'Xray error',
+};
+
+export class Status {
+  constructor(data) {
+    this.cpu = new CurTotal(0, 0);
+    this.cpuCores = 0;
+    this.logicalPro = 0;
+    this.cpuSpeedMhz = 0;
+    this.disk = new CurTotal(0, 0);
+    this.loads = [0, 0, 0];
+    this.mem = new CurTotal(0, 0);
+    this.netIO = { up: 0, down: 0 };
+    this.netTraffic = { sent: 0, recv: 0 };
+    this.publicIP = { ipv4: 0, ipv6: 0 };
+    this.swap = new CurTotal(0, 0);
+    this.tcpCount = 0;
+    this.udpCount = 0;
+    this.uptime = 0;
+    this.appUptime = 0;
+    this.appStats = { threads: 0, mem: 0, uptime: 0 };
+    this.xray = { state: 'stop', stateMsg: '', errorMsg: '', version: '', color: '' };
+
+    if (data == null) return;
+
+    this.cpu = new CurTotal(data.cpu, 100);
+    this.cpuCores = data.cpuCores;
+    this.logicalPro = data.logicalPro;
+    this.cpuSpeedMhz = data.cpuSpeedMhz;
+    this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
+    this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
+    this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
+    this.netIO = data.netIO ?? this.netIO;
+    this.netTraffic = data.netTraffic ?? this.netTraffic;
+    this.publicIP = data.publicIP ?? this.publicIP;
+    this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
+    this.tcpCount = data.tcpCount ?? 0;
+    this.udpCount = data.udpCount ?? 0;
+    this.uptime = data.uptime ?? 0;
+    this.appUptime = data.appUptime ?? 0;
+    this.appStats = data.appStats ?? this.appStats;
+    this.xray = { ...this.xray, ...(data.xray || {}) };
+    this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
+    this.xray.stateMsg = XRAY_STATE_MESSAGES[this.xray.state] ?? 'Unknown';
+  }
+}

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

@@ -0,0 +1,273 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { SyncOutlined } from '@ant-design/icons-vue';
+
+import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
+
+const { t } = useI18n();
+import {
+  Inbound,
+  Protocols,
+  USERS_SECURITY,
+  TLS_FLOW_CONTROL,
+} from '@/models/inbound.js';
+import DateTimePicker from '@/components/DateTimePicker.vue';
+
+// Bulk-add up to 500 clients in one go. The legacy panel offers five
+// generation modes — this component preserves them all:
+//   0: Random         — N fully-random emails (no prefix)
+//   1: Random+Prefix  — N random emails preceded by `prefix`
+//   2: Random+Prefix+Num     — emails like `<rand><prefix><num>` for num in [first..last]
+//   3: Random+Prefix+Num+Postfix — same + appended postfix
+//   4: Prefix+Num+Postfix    — no random part, just `<prefix><num><postfix>`
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  dbInbound: { type: Object, default: null },
+  subEnable: { type: Boolean, default: false },
+  tgBotEnable: { type: Boolean, default: false },
+  ipLimitEnable: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+// === Reactive form state ===========================================
+// Cloned inbound (so canEnableTlsFlow() works).
+const inbound = ref(null);
+const saving = ref(false);
+const delayedStart = ref(false);
+
+const form = reactive({
+  emailMethod: 0,
+  firstNum: 1,
+  lastNum: 1,
+  emailPrefix: '',
+  emailPostfix: '',
+  quantity: 1,
+  security: USERS_SECURITY.AUTO,
+  flow: '',
+  subId: '',
+  tgId: 0,
+  limitIp: 0,
+  totalGB: 0,
+  expiryTime: 0, // ms epoch; negative => delayed start days
+  reset: 0,
+});
+
+const expiryDate = computed({
+  get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
+  set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
+});
+
+const delayedExpireDays = computed({
+  get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
+  set: (days) => { form.expiryTime = -86400000 * (days || 0); },
+});
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (!props.dbInbound) return;
+  inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
+  // Reset all form fields on every open — bulk add is intentionally
+  // stateless between sessions (legacy resets on .show()).
+  form.emailMethod = 0;
+  form.firstNum = 1;
+  form.lastNum = 1;
+  form.emailPrefix = '';
+  form.emailPostfix = '';
+  form.quantity = 1;
+  form.security = USERS_SECURITY.AUTO;
+  form.flow = '';
+  form.subId = '';
+  form.tgId = 0;
+  form.limitIp = 0;
+  form.totalGB = 0;
+  form.expiryTime = 0;
+  form.reset = 0;
+  delayedStart.value = false;
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+function makeNewClient(parsed) {
+  switch (parsed.protocol) {
+    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
+    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
+    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
+    case Protocols.SHADOWSOCKS: {
+      const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
+      return new Inbound.ShadowsocksSettings.Shadowsocks(method);
+    }
+    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
+    default: return null;
+  }
+}
+
+function buildClients() {
+  if (!inbound.value) return [];
+  const out = [];
+  const method = form.emailMethod;
+  let start;
+  let end;
+  if (method > 1) {
+    start = form.firstNum;
+    end = form.lastNum + 1;
+  } else {
+    start = 0;
+    end = form.quantity;
+  }
+  const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
+  const useNum = method > 1;
+  const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
+
+  for (let i = start; i < end; i++) {
+    const c = makeNewClient(inbound.value);
+    if (!c) continue;
+    if (method === 4) c.email = '';
+    c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
+
+    if (form.subId.length > 0) c.subId = form.subId;
+    c.tgId = form.tgId;
+    c.security = form.security;
+    c.limitIp = form.limitIp;
+    // Use the clien's totalGB setter (ms epoch and bytes already handled
+    // identically for bulk and single client paths).
+    c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
+    c.expiryTime = form.expiryTime;
+    if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
+    c.reset = form.reset;
+    out.push(c);
+  }
+  return out;
+}
+
+async function submit() {
+  const clients = buildClients();
+  if (clients.length === 0) return;
+
+  saving.value = true;
+  try {
+    const payload = {
+      id: props.dbInbound.id,
+      // Clients all serialize via toString() — same shape the single-
+      // client modal posts. Joining with `,` lets the Go side parse the
+      // outer array directly.
+      settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
+    };
+    const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
+    if (msg?.success) {
+      emit('saved');
+      close();
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
+    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
+    <a-form v-if="inbound" :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+      <a-form-item :label="t('pages.client.method')">
+        <a-select v-model:value="form.emailMethod">
+          <a-select-option :value="0">Random</a-select-option>
+          <a-select-option :value="1">Random + Prefix</a-select-option>
+          <a-select-option :value="2">Random + Prefix + Num</a-select-option>
+          <a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
+          <a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
+        <a-input-number v-model:value="form.firstNum" :min="1" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
+        <a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
+        <a-input v-model:value="form.emailPrefix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
+        <a-input v-model:value="form.emailPostfix" />
+      </a-form-item>
+      <a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
+        <a-input-number v-model:value="form.quantity" :min="1" :max="500" />
+      </a-form-item>
+
+      <a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
+        <a-select v-model:value="form.security">
+          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
+        <a-select v-model:value="form.flow">
+          <a-select-option value="">{{ t('none') }}</a-select-option>
+          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="subEnable">
+        <template #label>
+          {{ t('subscription.title') }}
+          <SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
+        </template>
+        <a-input v-model:value="form.subId" />
+      </a-form-item>
+
+      <a-form-item v-if="tgBotEnable" label="Telegram ID">
+        <a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
+      </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>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
+        </template>
+        <a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
+      </a-form-item>
+
+      <a-form-item :label="t('pages.client.delayedStart')">
+        <a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
+      </a-form-item>
+
+      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
+        <a-input-number v-model:value="delayedExpireDays" :min="0" />
+      </a-form-item>
+
+      <a-form-item v-else>
+        <template #label>
+          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
+          }}</a-tooltip>
+        </template>
+        <DateTimePicker v-model:value="expiryDate" />
+      </a-form-item>
+
+      <a-form-item v-if="form.expiryTime !== 0">
+        <template #label>
+          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
+        </template>
+        <a-input-number v-model:value="form.reset" :min="0" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.random-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-primary-color, #1890ff);
+}
+</style>

+ 394 - 0
frontend/src/pages/inbounds/ClientFormModal.vue

@@ -0,0 +1,394 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+
+import {
+  HttpUtil,
+  RandomUtil,
+  SizeFormatter,
+  ColorUtils,
+} from '@/utils';
+import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
+import DateTimePicker from '@/components/DateTimePicker.vue';
+
+const { t } = useI18n();
+
+// Add OR edit a single client on a multi-user inbound (VMess / VLess /
+// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
+// flows through the same modal — same here.
+//
+// On submit we serialize the client via its toString() (which is just
+// JSON.stringify of toJson()) and post it inside a one-element clients
+// array so the Go side reuses the same parsing path as the inbound
+// settings update.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
+  dbInbound: { type: Object, default: null },
+  clientIndex: { type: Number, default: null },
+  // Sidecar config from the inbounds page — controls visibility of
+  // the Subscription, Telegram, and IP-limit fields.
+  subEnable: { type: Boolean, default: false },
+  tgBotEnable: { type: Boolean, default: false },
+  ipLimitEnable: { type: Boolean, default: false },
+  trafficDiff: { type: Number, default: 0 },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+// === Reactive draft =================================================
+const inbound = ref(null);
+const client = ref(null);
+const oldClientId = ref('');
+const clientStats = ref(null);
+
+const saving = ref(false);
+const delayedStart = ref(false);
+
+const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+const protocol = computed(() => inbound.value?.protocol);
+const isVmessOrVless = computed(() =>
+  protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
+);
+const isTrojanOrSS = computed(() =>
+  protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
+);
+
+const expiryDate = computed({
+  get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
+  set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
+});
+
+const delayedExpireDays = computed({
+  get: () => {
+    if (!client.value || client.value.expiryTime >= 0) return 0;
+    return client.value.expiryTime / -86400000;
+  },
+  set: (days) => {
+    if (!client.value) return;
+    client.value.expiryTime = -86400000 * (days || 0);
+  },
+});
+
+const totalGB = computed({
+  get: () => {
+    if (!client.value || !client.value.totalGB) return 0;
+    return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
+  },
+  set: (gb) => {
+    if (!client.value) return;
+    client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
+  },
+});
+
+const isExpired = computed(() => {
+  if (props.mode !== 'edit' || !client.value) return false;
+  return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
+});
+const isTrafficExhausted = computed(() => {
+  if (!clientStats.value || clientStats.value.total <= 0) return false;
+  return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
+});
+
+function getClientId(proto, c) {
+  switch (proto) {
+    case Protocols.TROJAN: return c.password;
+    case Protocols.SHADOWSOCKS: return c.email;
+    case Protocols.HYSTERIA: return c.auth;
+    default: return c.id;
+  }
+}
+
+function makeNewClient(proto, parsed) {
+  switch (proto) {
+    case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
+    case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
+    case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
+    case Protocols.SHADOWSOCKS: {
+      const method = parsed.settings.method;
+      return new Inbound.ShadowsocksSettings.Shadowsocks(
+        method,
+        RandomUtil.randomShadowsocksPassword(method),
+      );
+    }
+    case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
+    default: return null;
+  }
+}
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (!props.dbInbound) return;
+  const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
+  inbound.value = parsed;
+  delayedStart.value = false;
+
+  if (props.mode === 'edit') {
+    const idx = props.clientIndex ?? 0;
+    client.value = parsed.clients[idx];
+    if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
+    oldClientId.value = getClientId(parsed.protocol, client.value);
+  } else {
+    const c = makeNewClient(parsed.protocol, parsed);
+    if (c) parsed.clients.push(c);
+    client.value = parsed.clients[parsed.clients.length - 1];
+    oldClientId.value = '';
+  }
+
+  clientStats.value = (props.dbInbound.clientStats || []).find(
+    (s) => s.email === client.value?.email,
+  ) || null;
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+function randomEmail() {
+  if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
+}
+function randomId() {
+  if (client.value) client.value.id = RandomUtil.randomUUID();
+}
+function randomPassword() {
+  if (!client.value || !inbound.value) return;
+  if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
+    client.value.password = RandomUtil.randomShadowsocksPassword(
+      inbound.value.settings.method,
+    );
+  } else {
+    client.value.password = RandomUtil.randomSeq(10);
+  }
+}
+function randomAuth() {
+  if (client.value) client.value.auth = RandomUtil.randomSeq(10);
+}
+function randomSubId() {
+  if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
+}
+
+const clientIpsText = ref('');
+async function loadClientIps() {
+  if (!client.value?.email) return;
+  const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
+  if (!msg?.success) {
+    clientIpsText.value = msg?.obj || '';
+    return;
+  }
+  let ips = msg.obj;
+  if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
+    try {
+      const parsed = JSON.parse(ips);
+      ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
+    } catch (_e) {
+      // leave as raw
+    }
+  }
+  clientIpsText.value = ips || '';
+}
+async function clearClientIps() {
+  if (!client.value?.email) return;
+  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
+  if (msg?.success) clientIpsText.value = '';
+}
+
+async function resetClientTraffic() {
+  if (!clientStats.value || !client.value?.email) return;
+  const msg = await HttpUtil.post(
+    `/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
+  );
+  if (msg?.success) {
+    clientStats.value.up = 0;
+    clientStats.value.down = 0;
+  }
+}
+
+async function submit() {
+  if (!client.value || !inbound.value) return;
+  saving.value = true;
+  try {
+    const payload = {
+      id: props.dbInbound.id,
+      settings: `{"clients": [${client.value.toString()}]}`,
+    };
+    const url = props.mode === 'edit'
+      ? `/panel/api/inbounds/updateClient/${oldClientId.value}`
+      : '/panel/api/inbounds/addClient';
+    const msg = await HttpUtil.post(url, payload);
+    if (msg?.success) {
+      emit('saved');
+      close();
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+
+const title = computed(() =>
+  props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
+);
+</script>
+
+<template>
+  <a-modal :open="open" :title="title"
+    :ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
+    :confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
+    <a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
+      {{ t('depleted') }}
+    </a-tag>
+
+    <a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ md: { span: 8 } }"
+      :wrapper-col="{ md: { span: 14 } }">
+      <a-form-item :label="t('enable')">
+        <a-switch v-model:checked="client.enable" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          {{ t('pages.inbounds.email') }}
+          <SyncOutlined class="random-icon" @click="randomEmail" />
+        </template>
+        <a-input v-model:value="client.email" />
+      </a-form-item>
+
+      <a-form-item v-if="isTrojanOrSS">
+        <template #label>
+          {{ t('password') }}
+          <SyncOutlined class="random-icon" @click="randomPassword" />
+        </template>
+        <a-input v-model:value="client.password" />
+      </a-form-item>
+
+      <a-form-item v-if="protocol === Protocols.HYSTERIA">
+        <template #label>
+          {{ t('password') }}
+          <SyncOutlined class="random-icon" @click="randomAuth" />
+        </template>
+        <a-input v-model:value="client.auth" />
+      </a-form-item>
+
+      <a-form-item v-if="isVmessOrVless">
+        <template #label>
+          ID
+          <SyncOutlined class="random-icon" @click="randomId" />
+        </template>
+        <a-input v-model:value="client.id" />
+      </a-form-item>
+
+      <a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
+        <a-select v-model:value="client.security">
+          <a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
+            {{ key }}
+          </a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="client.email && subEnable">
+        <template #label>
+          {{ t('subscription.title') }}
+          <SyncOutlined class="random-icon" @click="randomSubId" />
+        </template>
+        <a-input v-model:value="client.subId" />
+      </a-form-item>
+
+      <a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
+        <a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
+      </a-form-item>
+
+      <a-form-item v-if="client.email" :label="t('comment')">
+        <a-input v-model:value="client.comment" />
+      </a-form-item>
+
+      <a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
+        <a-input-number v-model:value="client.limitIp" :min="0" />
+      </a-form-item>
+
+      <a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
+        :label="t('pages.inbounds.IPLimitlog')">
+        <a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
+          :auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
+        <a-button type="link" size="small" danger @click="clearClientIps">
+          <template #icon>
+            <DeleteOutlined />
+          </template>
+          {{ t('pages.inbounds.IPLimitlogclear') }}
+        </a-button>
+      </a-form-item>
+
+      <a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
+        <a-select v-model:value="client.flow">
+          <a-select-option value="">{{ t('none') }}</a-select-option>
+          <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
+            {{ key }}
+          </a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
+        <a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
+        </template>
+        <a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
+      </a-form-item>
+
+      <a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
+        <a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
+          {{ SizeFormatter.sizeFormat(clientStats.up) }} /
+          {{ SizeFormatter.sizeFormat(clientStats.down) }}
+          ({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
+        </a-tag>
+        <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
+          <RetweetOutlined class="action-icon" @click="resetClientTraffic" />
+        </a-tooltip>
+      </a-form-item>
+
+      <a-form-item :label="t('pages.client.delayedStart')">
+        <a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
+      </a-form-item>
+
+      <a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
+        <a-input-number v-model:value="delayedExpireDays" :min="0" />
+      </a-form-item>
+
+      <a-form-item v-else>
+        <template #label>
+          <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
+            }}</a-tooltip>
+        </template>
+        <DateTimePicker v-model:value="expiryDate" />
+        <a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
+      </a-form-item>
+
+      <a-form-item v-if="client.expiryTime !== 0">
+        <template #label>
+          <a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
+        </template>
+        <a-input-number v-model:value="client.reset" :min="0" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.status-banner {
+  display: block;
+  margin-bottom: 10px;
+  text-align: center;
+}
+
+.random-icon,
+.action-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-primary-color, #1890ff);
+}
+</style>

+ 610 - 0
frontend/src/pages/inbounds/ClientRowTable.vue

@@ -0,0 +1,610 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  EditOutlined,
+  InfoCircleOutlined,
+  QrcodeOutlined,
+  RetweetOutlined,
+  DeleteOutlined,
+  EllipsisOutlined,
+} from '@ant-design/icons-vue';
+import { Modal } from 'ant-design-vue';
+
+import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
+import InfinityIcon from '@/components/InfinityIcon.vue';
+import { useDatepicker } from '@/composables/useDatepicker.js';
+
+const { datepicker } = useDatepicker();
+
+const { t } = useI18n();
+
+// Per-inbound expand-row content. CSS-grid layout (not a nested
+// <a-table>) so it sits flush inside the parent's expanded cell.
+// No API calls here — events bubble to the parent's modals.
+
+const props = defineProps({
+  dbInbound: { type: Object, required: true },
+  isMobile: { type: Boolean, default: false },
+  trafficDiff: { type: Number, default: 0 },
+  expireDiff: { type: Number, default: 0 },
+  onlineClients: { type: Array, default: () => [] },
+  lastOnlineMap: { type: Object, default: () => ({}) },
+  isDarkTheme: { type: Boolean, default: false },
+});
+
+const emit = defineEmits([
+  'edit-client',
+  'qrcode-client',
+  'info-client',
+  'reset-traffic-client',
+  'delete-client',
+  'toggle-enable-client',
+]);
+
+const inbound = computed(() => props.dbInbound.toInbound());
+const clients = computed(() => inbound.value?.clients || []);
+
+// === Per-client stats lookup =======================================
+const statsMap = computed(() => {
+  const m = new Map();
+  for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
+  return m;
+});
+function statsFor(email) {
+  return email ? statsMap.value.get(email) : null;
+}
+
+function getUp(email) { return statsFor(email)?.up || 0; }
+function getDown(email) { return statsFor(email)?.down || 0; }
+function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
+function getRem(email) {
+  const s = statsFor(email);
+  if (!s) return 0;
+  const r = s.total - s.up - s.down;
+  return r > 0 ? r : 0;
+}
+function getAllTime(email) {
+  const s = statsFor(email);
+  if (!s) return 0;
+  // allTime is the cumulative-historical counter; never let it dip
+  // below up+down (manual edits / partial migrations can push it under).
+  const current = (s.up || 0) + (s.down || 0);
+  return s.allTime > current ? s.allTime : current;
+}
+function isClientDepleted(email) {
+  const s = statsFor(email);
+  if (!s) return false;
+  const total = s.total ?? 0;
+  const used = (s.up ?? 0) + (s.down ?? 0);
+  if (total > 0 && used >= total) return true;
+  const exp = s.expiryTime ?? 0;
+  if (exp > 0 && Date.now() >= exp) return true;
+  return false;
+}
+function isClientOnline(email) {
+  return !!email && props.onlineClients.includes(email);
+}
+function lastOnlineLabel(email) {
+  const ts = props.lastOnlineMap[email];
+  if (!ts) return '-';
+  return IntlUtil.formatDate(ts, datepicker.value);
+}
+
+function statsProgress(email) {
+  const s = statsFor(email);
+  if (!s) return 0;
+  if (s.total === 0) return 100;
+  return (100 * (s.down + s.up)) / s.total;
+}
+function expireProgress(expTime, reset) {
+  const now = Date.now();
+  const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
+  const resetSec = reset * 86400;
+  if (remainedSec >= resetSec) return 0;
+  return 100 * (1 - remainedSec / resetSec);
+}
+function clientStatsColor(email) {
+  return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
+}
+function statsExpColor(email) {
+  // AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
+  // matches the row's traffic/expiry tags.
+  const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
+  if (!email) return PURPLE;
+  const s = statsFor(email);
+  if (!s) return PURPLE;
+  const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
+  const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
+  if (a === 'red' || b === 'red') return DANGER;
+  if (a === 'orange' || b === 'orange') return WARN;
+  if (a === 'green' || b === 'green') return SUCCESS;
+  return PURPLE;
+}
+
+const isRemovable = computed(() => clients.value.length > 1);
+
+function totalGbDisplay(client) {
+  if (!client.totalGB || client.totalGB <= 0) return '';
+  return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
+}
+
+const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
+
+function statusBadgeColor(client) {
+  if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
+  return statsExpColor(client.email);
+}
+
+// === Action confirms ==============================================
+function confirmReset(client) {
+  Modal.confirm({
+    title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
+    content: t('pages.inbounds.resetTrafficContent'),
+    okText: t('reset'),
+    cancelText: t('cancel'),
+    onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
+  });
+}
+function confirmDelete(client) {
+  Modal.confirm({
+    title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
+    content: t('pages.inbounds.deleteClientContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
+  });
+}
+
+// Stable row key for v-for — falls back through email/id/password
+// because not every protocol fills the same field.
+function rowKey(client) {
+  return client.email || client.id || client.password || JSON.stringify(client);
+}
+</script>
+
+<template>
+  <div class="client-list" :class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme }">
+    <!-- ============== Header (desktop only) ============== -->
+    <div v-if="!isMobile" class="client-row client-list-header">
+      <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-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
+      <div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
+    </div>
+
+    <!-- ============== Body rows ============== -->
+    <div v-for="client in clients" :key="rowKey(client)" class="client-row">
+      <!-- Desktop: action icon row | Mobile: dropdown menu -->
+      <div class="cell cell-actions">
+        <template v-if="!isMobile">
+          <a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
+            <QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
+          </a-tooltip>
+          <a-tooltip :title="t('edit')">
+            <EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
+          </a-tooltip>
+          <a-tooltip :title="t('info')">
+            <InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
+          </a-tooltip>
+          <a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
+            <RetweetOutlined class="row-icon" @click="confirmReset(client)" />
+          </a-tooltip>
+          <a-tooltip v-if="isRemovable" :title="t('delete')">
+            <DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
+          </a-tooltip>
+        </template>
+        <a-dropdown v-else :trigger="['click']">
+          <EllipsisOutlined class="row-icon" @click.prevent />
+          <template #overlay>
+            <a-menu>
+              <a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
+                <QrcodeOutlined /> {{ t('qrCode') }}
+              </a-menu-item>
+              <a-menu-item @click="emit('edit-client', { dbInbound, client })">
+                <EditOutlined /> {{ t('edit') }}
+              </a-menu-item>
+              <a-menu-item @click="emit('info-client', { dbInbound, client })">
+                <InfoCircleOutlined /> {{ t('info') }}
+              </a-menu-item>
+              <a-menu-item v-if="client.email" @click="confirmReset(client)">
+                <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+              </a-menu-item>
+              <a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
+                <DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
+              </a-menu-item>
+            </a-menu>
+          </template>
+        </a-dropdown>
+      </div>
+
+      <!-- Enable switch (hidden on mobile, lives in dropdown) -->
+      <div v-if="!isMobile" class="cell cell-enable">
+        <a-switch :checked="client.enable" size="small"
+          @change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
+      </div>
+
+      <!-- Online tag (desktop only) -->
+      <div v-if="!isMobile" class="cell cell-online">
+        <a-popover>
+          <template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
+          <a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
+          <a-tag v-else>{{ t('offline') }}</a-tag>
+        </a-popover>
+      </div>
+
+      <!-- Client identity: status dot + email + comment -->
+      <div class="cell cell-client">
+        <a-tooltip>
+          <template #title>
+            <template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
+            <template v-else-if="!client.enable">{{ t('disabled') }}</template>
+            <template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
+            <template v-else>{{ t('offline') }}</template>
+          </template>
+          <a-badge :color="statusBadgeColor(client)" />
+        </a-tooltip>
+        <div class="client-id-stack">
+          <a-tooltip :title="client.email">
+            <span class="client-email">{{ client.email }}</span>
+          </a-tooltip>
+          <span v-if="client.comment && client.comment.trim()" class="client-comment">
+            {{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
+          </span>
+        </div>
+      </div>
+
+      <!-- Traffic with progress bar (desktop only) -->
+      <div v-if="!isMobile" class="cell cell-traffic">
+        <a-popover>
+          <template v-if="client.email" #content>
+            <table cellpadding="2">
+              <tbody>
+                <tr>
+                  <td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
+                  <td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
+                </tr>
+                <tr v-if="client.totalGB > 0">
+                  <td>{{ t('remained') }}</td>
+                  <td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
+                </tr>
+              </tbody>
+            </table>
+          </template>
+          <div class="usage-bar">
+            <span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
+            <a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
+              :show-info="false" :percent="statsProgress(client.email)" size="small" />
+            <a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)" :show-info="false"
+              :status="isClientDepleted(client.email) ? 'exception' : ''" :percent="statsProgress(client.email)"
+              size="small" />
+            <a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
+            <span class="usage-text">
+              <InfinityIcon v-if="isUnlimitedTotal(client)" />
+              <template v-else>{{ totalGbDisplay(client) }}</template>
+            </span>
+          </div>
+        </a-popover>
+      </div>
+
+      <!-- All-time traffic (desktop only) -->
+      <div v-if="!isMobile" class="cell cell-alltime">
+        <a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
+      </div>
+
+      <!-- Expiry (desktop only) -->
+      <div v-if="!isMobile" class="cell cell-expiry">
+        <template v-if="client.expiryTime !== 0 && client.reset > 0">
+          <a-popover>
+            <template #content>
+              <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
+              <span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
+            </template>
+            <div class="usage-bar">
+              <span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
+              <a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
+                :percent="expireProgress(client.expiryTime, client.reset)" size="small" />
+              <span class="usage-text">{{ client.reset }}d</span>
+            </div>
+          </a-popover>
+        </template>
+        <a-popover v-else-if="client.expiryTime !== 0">
+          <template #content>
+            <span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
+            <span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
+          </template>
+          <a-tag :style="{ minWidth: '50px', border: 'none' }"
+            :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
+            {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+          </a-tag>
+        </a-popover>
+        <a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
+          class="infinite-tag">
+          <InfinityIcon />
+        </a-tag>
+      </div>
+
+      <!-- Mobile-only summary popover (collapses traffic + expiry) -->
+      <div v-if="isMobile" class="cell cell-mobile-info">
+        <a-popover placement="bottomLeft" trigger="click">
+          <template #content>
+            <table cellpadding="2">
+              <tbody>
+                <tr>
+                  <td colspan="2" class="text-center">{{ t('pages.inbounds.traffic') }}</td>
+                </tr>
+                <tr>
+                  <td class="num-cell">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</td>
+                  <td class="num-cell">
+                    <InfinityIcon v-if="isUnlimitedTotal(client)" />
+                    <template v-else>{{ totalGbDisplay(client) }}</template>
+                  </td>
+                </tr>
+                <tr>
+                  <td colspan="2" class="text-center">
+                    <a-divider style="margin: 0" />
+                    {{ t('pages.inbounds.expireDate') }}
+                  </td>
+                </tr>
+                <tr>
+                  <td colspan="2" class="text-center">
+                    <a-tag v-if="client.expiryTime > 0">
+                      {{ IntlUtil.formatRelativeTime(client.expiryTime) }}
+                    </a-tag>
+                    <a-tag v-else-if="client.expiryTime < 0" color="green">
+                      {{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
+                    </a-tag>
+                    <a-tag v-else color="purple">
+                      <InfinityIcon />
+                    </a-tag>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </template>
+          <a-button shape="round" size="small">
+            <InfoCircleOutlined />
+          </a-button>
+        </a-popover>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.client-list {
+  margin: -8px 0;
+  font-size: 13px;
+}
+
+.client-row {
+  display: grid;
+  grid-template-columns:
+    140px
+    /* actions */
+    60px
+    /* enable */
+    80px
+    /* online */
+    minmax(160px, 2fr)
+    /* client identity */
+    minmax(160px, 2fr)
+    /* traffic */
+    130px
+    /* all-time */
+    140px;
+  /* expiry */
+  gap: 12px;
+  align-items: center;
+  padding: 8px 16px;
+  border-top: 1px solid rgba(128, 128, 128, 0.12);
+}
+
+.client-row:last-child {
+  border-bottom: 1px solid rgba(128, 128, 128, 0.12);
+}
+
+.client-list-header {
+  font-weight: 500;
+  font-size: 12px;
+  opacity: 0.65;
+  padding-top: 6px;
+  padding-bottom: 6px;
+  border-top: none;
+  text-transform: uppercase;
+  letter-spacing: 0.02em;
+}
+
+/* Mobile collapses to a 3-column row: action menu, client info, info popover. */
+.client-list.is-mobile .client-row {
+  grid-template-columns: 36px minmax(0, 1fr) 36px;
+  padding: 8px 12px;
+}
+
+.cell {
+  min-width: 0;
+  /* allow grid children to shrink instead of overflowing */
+}
+
+.cell-actions,
+.cell-enable,
+.cell-online,
+.cell-alltime,
+.cell-mobile-info {
+  text-align: center;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.cell-actions {
+  justify-content: flex-start;
+}
+
+.cell-client {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+
+.cell-traffic,
+.cell-expiry {
+  text-align: center;
+}
+
+.client-list-header .cell {
+  text-align: center;
+}
+
+.client-list-header .cell-actions,
+.client-list-header .cell-client {
+  text-align: left;
+}
+
+/* Action icons */
+.row-icon {
+  font-size: 16px;
+  cursor: pointer;
+  padding: 0 2px;
+  color: inherit;
+  transition: color 120ms ease;
+}
+
+.row-icon:hover {
+  color: var(--ant-color-primary, #1677ff);
+}
+
+.row-icon.danger {
+  color: #ff4d4f;
+}
+
+.danger {
+  color: #ff4d4f;
+}
+
+/* Client identity stack (badge + email + comment) */
+.client-id-stack {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.client-email {
+  font-weight: 500;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: inline-block;
+}
+
+.client-comment {
+  font-size: 11px;
+  opacity: 0.7;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: inline-block;
+}
+
+/* Traffic / expiry inline bar:  text  |  progress  |  text */
+.usage-bar {
+  display: grid;
+  grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
+  align-items: center;
+  gap: 6px;
+}
+
+.usage-text {
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.usage-bar :deep(.ant-progress) {
+  margin: 0;
+  line-height: 1;
+}
+
+.infinite-tag {
+  min-width: 50px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+/* Mobile popover content table */
+.text-center {
+  text-align: center;
+}
+
+.num-cell {
+  text-align: right;
+  font-size: 12px;
+  padding: 2px 6px;
+}
+
+/* Strip AD-Vue's default expanded-cell padding so the grid sits
+ * flush against the inbound row's left/right edges. */
+:deep(.ant-table-expanded-row > .ant-table-cell) {
+  padding: 0 !important;
+}
+
+/* ===== Mobile polish ===============================================
+ * On phones the row collapses to [actions][client][info]. Give those
+ * cells room and bump the touch targets so the per-client action
+ * dropdown + info popover are easier to hit with a thumb. */
+@media (max-width: 768px) {
+  .client-list.is-mobile .client-row {
+    grid-template-columns: 40px minmax(0, 1fr) 40px;
+    gap: 8px;
+    padding: 10px 10px;
+  }
+
+  .client-list.is-mobile .row-icon {
+    font-size: 20px;
+    padding: 6px;
+  }
+
+  .client-list.is-mobile .cell-mobile-info .ant-btn {
+    width: 32px;
+    height: 32px;
+  }
+
+  /* Make the email more readable; the comment can stay smaller. */
+  .client-list.is-mobile .client-email {
+    font-size: 14px;
+    font-weight: 500;
+  }
+
+  .client-list.is-mobile .client-comment {
+    font-size: 11px;
+  }
+
+  /* Bigger status badge so depleted/online state is visible at a glance. */
+  .client-list.is-mobile .cell-client :deep(.ant-badge-status-dot) {
+    width: 9px;
+    height: 9px;
+  }
+
+  /* Row separators feel cleaner with a slight surface tint per row
+   * — easier to scan than a hairline border on dark backgrounds. */
+  .client-list.is-mobile .client-row:not(.client-list-header) {
+    background: rgba(128, 128, 128, 0.04);
+    border-radius: 8px;
+    margin: 4px 8px;
+    border: none !important;
+  }
+
+  .client-list.is-mobile .client-row:not(.client-list-header):last-child {
+    border: none !important;
+  }
+}
+</style>

+ 1790 - 0
frontend/src/pages/inbounds/InboundFormModal.vue

@@ -0,0 +1,1790 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import dayjs from 'dayjs';
+import { message } from 'ant-design-vue';
+import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+
+import {
+  HttpUtil,
+  RandomUtil,
+  NumberFormatter,
+  SizeFormatter,
+  Wireguard,
+} from '@/utils';
+import {
+  Inbound,
+  Protocols,
+  SSMethods,
+  USERS_SECURITY,
+  TLS_FLOW_CONTROL,
+  SNIFFING_OPTION,
+  TLS_VERSION_OPTION,
+  TLS_CIPHER_OPTION,
+  UTLS_FINGERPRINT,
+  ALPN_OPTION,
+  USAGE_OPTION,
+  DOMAIN_STRATEGY_OPTION,
+  TCP_CONGESTION_OPTION,
+  MODE_OPTION,
+} from '@/models/inbound.js';
+import { DBInbound } from '@/models/dbinbound.js';
+import FinalMaskForm from '@/components/FinalMaskForm.vue';
+import DateTimePicker from '@/components/DateTimePicker.vue';
+import { useNodeList } from '@/composables/useNodeList.js';
+
+const { t } = useI18n();
+
+// Node selector — Phase 1 multi-node deployment. Shows all enabled
+// nodes regardless of online state so the form is usable while a node
+// is briefly offline; the backend's fail-fast path will surface the
+// real error when the user submits.
+const { nodes: availableNodes } = useNodeList();
+const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable));
+
+// Phase 5f-iii-b: structured per-protocol/per-transport forms instead
+// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound
+// pair so the existing model helpers (.toString(), .canEnableTls(),
+// genAllLinks(), addPeer(), etc.) keep working unchanged. The
+// "Advanced" tab still exposes the full streamSettings JSON for
+// transport variants (KCP/XHTTP/sockopt/finalmask) we don't yet have
+// dedicated UI for.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
+  dbInbound: { type: Object, default: null },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'];
+const PROTOCOLS = Object.values(Protocols);
+const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+
+// === Reactive state ================================================
+// Cloned on every open so cancelling the modal doesn't mutate the row.
+const inbound = ref(null);
+const dbForm = ref(null);
+const saving = ref(false);
+const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
+// Cached default cert/key paths from /panel/setting/defaultSettings —
+// powers the "Set default cert" button on the TLS form.
+const defaultCert = ref('');
+const defaultKey = ref('');
+
+// Lookup tables for the option dropdowns.
+const TLS_VERSIONS = Object.values(TLS_VERSION_OPTION);
+const CIPHER_SUITES = Object.entries(TLS_CIPHER_OPTION); // [label, value]
+const FINGERPRINTS = Object.values(UTLS_FINGERPRINT);
+const ALPNS = Object.values(ALPN_OPTION);
+const USAGES = Object.values(USAGE_OPTION);
+const DOMAIN_STRATEGIES = Object.values(DOMAIN_STRATEGY_OPTION);
+const TCP_CONGESTIONS = Object.values(TCP_CONGESTION_OPTION);
+const MODE_OPTIONS = Object.values(MODE_OPTION);
+
+// External proxy is a single switch in the UI but a list in the model:
+// flipping it on seeds one row pre-filled with the current host:port.
+const externalProxy = computed({
+  get: () => Array.isArray(inbound.value?.stream?.externalProxy)
+    && inbound.value.stream.externalProxy.length > 0,
+  set: (v) => {
+    if (!inbound.value?.stream) return;
+    if (v) {
+      inbound.value.stream.externalProxy = [{
+        forceTls: 'same',
+        dest: window.location.hostname,
+        port: inbound.value.port,
+        remark: '',
+      }];
+    } else {
+      inbound.value.stream.externalProxy = [];
+    }
+  },
+});
+
+// Derived helpers — each is a computed off `inbound` so flips of
+// protocol / network / security re-render the right blocks.
+const protocol = computed(() => inbound.value?.protocol);
+const network = computed({
+  get: () => inbound.value?.stream?.network,
+  set: (v) => onNetworkChange(v),
+});
+const security = computed({
+  get: () => inbound.value?.stream?.security,
+  set: (v) => { if (inbound.value?.stream) inbound.value.stream.security = v; },
+});
+
+const isMultiUser = computed(() => {
+  if (!inbound.value) return false;
+  switch (inbound.value.protocol) {
+    case Protocols.VMESS:
+    case Protocols.VLESS:
+    case Protocols.TROJAN:
+    case Protocols.HYSTERIA:
+      return true;
+    case Protocols.SHADOWSOCKS:
+      return !!inbound.value.isSSMultiUser;
+    default:
+      return false;
+  }
+});
+
+const clientsArray = computed(() => {
+  if (!inbound.value) return [];
+  switch (inbound.value.protocol) {
+    case Protocols.VMESS: return inbound.value.settings.vmesses || [];
+    case Protocols.VLESS: return inbound.value.settings.vlesses || [];
+    case Protocols.TROJAN: return inbound.value.settings.trojans || [];
+    case Protocols.SHADOWSOCKS: return inbound.value.settings.shadowsockses || [];
+    case Protocols.HYSTERIA: return inbound.value.settings.hysterias || [];
+    default: return [];
+  }
+});
+
+const firstClient = computed(() => clientsArray.value[0] || null);
+const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true);
+const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
+const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true);
+const canEnableTlsFlow = computed(() => inbound.value?.canEnableTlsFlow?.() === true);
+
+// VLESS/Trojan TLS fallbacks — surfaced in the protocol tab when the
+// inbound is on TCP and (for VLESS) using no Xray-side encryption.
+const showFallbacks = computed(() => {
+  if (!inbound.value) return false;
+  if (inbound.value.stream?.network !== 'tcp') return false;
+  if (inbound.value.protocol === Protocols.VLESS) {
+    const enc = inbound.value.settings?.encryption;
+    return !enc || enc === 'none';
+  }
+  return inbound.value.protocol === Protocols.TROJAN;
+});
+
+function addFallback() {
+  inbound.value?.settings?.addFallback?.();
+}
+function delFallback(idx) {
+  inbound.value?.settings?.delFallback?.(idx);
+}
+
+// Date / GB bridges (legacy used moment via _expiryTime; we go direct).
+const expiryDate = computed({
+  get: () => (dbForm.value?.expiryTime > 0 ? dayjs(dbForm.value.expiryTime) : null),
+  set: (next) => { if (dbForm.value) dbForm.value.expiryTime = next ? next.valueOf() : 0; },
+});
+const totalGB = computed({
+  get: () => (dbForm.value?.total ? Math.round((dbForm.value.total / SizeFormatter.ONE_GB) * 100) / 100 : 0),
+  set: (gb) => { if (dbForm.value) dbForm.value.total = NumberFormatter.toFixed((gb || 0) * SizeFormatter.ONE_GB, 0); },
+});
+
+// Client total/expiry bridges (only relevant in add mode for new clients)
+const clientExpiryDate = computed({
+  get: () => (firstClient.value?.expiryTime > 0 ? dayjs(firstClient.value.expiryTime) : null),
+  set: (next) => { if (firstClient.value) firstClient.value.expiryTime = next ? next.valueOf() : 0; },
+});
+const clientTotalGB = computed({
+  get: () => firstClient.value?._totalGB ?? 0,
+  set: (gb) => { if (firstClient.value) firstClient.value._totalGB = gb || 0; },
+});
+
+// === Open / state management =======================================
+function loadFromDbInbound(dbIn) {
+  // Round-trip through Inbound.fromJson so subsequent edits get the
+  // structured class hierarchy (StreamSettings, TLS, Reality, etc.).
+  const parsed = Inbound.fromJson(dbIn.toInbound().toJson());
+  inbound.value = parsed;
+  // DBForm carries the persisted-fields the parsed Inbound doesn't:
+  // remark, enable, total, expiryTime, trafficReset, etc.
+  dbForm.value = new DBInbound(dbIn);
+  primeAdvancedJson();
+}
+
+function makeFreshInbound(proto) {
+  const ib = new Inbound();
+  ib.protocol = proto;
+  ib.settings = Inbound.Settings.getSettings(proto);
+  ib.port = RandomUtil.randomInteger(10000, 60000);
+  return ib;
+}
+
+function freshDbForm() {
+  const next = new DBInbound();
+  next.enable = true;
+  next.remark = '';
+  next.total = 0;
+  next.expiryTime = 0;
+  next.trafficReset = 'never';
+  return next;
+}
+
+function primeAdvancedJson() {
+  if (!inbound.value) return;
+  try {
+    advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
+  } catch (_e) { /* keep prior text */ }
+  try {
+    advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
+  } catch (_e) { /* keep prior text */ }
+  try {
+    advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
+  } catch (_e) { /* keep prior text */ }
+}
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (props.mode === 'edit' && props.dbInbound) {
+    loadFromDbInbound(props.dbInbound);
+  } else {
+    inbound.value = makeFreshInbound(Protocols.VLESS);
+    dbForm.value = freshDbForm();
+    primeAdvancedJson();
+  }
+  fetchDefaultCertSettings();
+});
+
+// In add mode, switching protocol restamps settings + re-syncs port.
+function onProtocolChange(next) {
+  if (props.mode === 'edit' || !inbound.value) return;
+  inbound.value.protocol = next;
+  inbound.value.settings = Inbound.Settings.getSettings(next);
+  primeAdvancedJson();
+}
+
+function onNetworkChange(next) {
+  if (!inbound.value?.stream) return;
+  inbound.value.stream.network = next;
+  // Mirror legacy streamNetworkChange: clear flow when TLS/Reality
+  // become unavailable; reset finalmask.udp when not KCP.
+  if (!inbound.value.canEnableTls()) inbound.value.stream.security = 'none';
+  if (!inbound.value.canEnableReality()) inbound.value.reality = false;
+  if (
+    inbound.value.protocol === Protocols.VLESS
+    && !inbound.value.canEnableTlsFlow()
+    && Array.isArray(inbound.value.settings.vlesses)
+  ) {
+    inbound.value.settings.vlesses.forEach((c) => { c.flow = ''; });
+  }
+  if (next !== 'kcp' && inbound.value.stream.finalmask) {
+    inbound.value.stream.finalmask.udp = [];
+  }
+}
+
+// === Random helpers wired to the form's sync icons ==================
+function randomEmail(target) {
+  if (target) target.email = RandomUtil.randomLowerAndNum(9);
+}
+function randomUuid(target) {
+  if (target) target.id = RandomUtil.randomUUID();
+}
+function randomPasswordSeq(target, len = 10) {
+  if (target) target.password = RandomUtil.randomSeq(len);
+}
+function randomSSPassword(target) {
+  if (target) target.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
+}
+function randomAuth(target) {
+  if (target) target.auth = RandomUtil.randomSeq(10);
+}
+function randomSubId(target) {
+  if (target) target.subId = RandomUtil.randomLowerAndNum(16);
+}
+function regenWgKeypair(target) {
+  const kp = Wireguard.generateKeypair();
+  target.publicKey = kp.publicKey;
+  target.privateKey = kp.privateKey;
+}
+function regenInboundWg() {
+  const kp = Wireguard.generateKeypair();
+  inbound.value.settings.pubKey = kp.publicKey;
+  inbound.value.settings.secretKey = kp.privateKey;
+}
+
+// === Reality keygen via existing API =================================
+async function genRealityKeypair() {
+  saving.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
+    if (msg?.success) {
+      inbound.value.stream.reality.privateKey = msg.obj.privateKey;
+      inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+
+function clearRealityKeypair() {
+  if (!inbound.value?.stream?.reality) return;
+  inbound.value.stream.reality.privateKey = '';
+  inbound.value.stream.reality.settings.publicKey = '';
+}
+
+async function genMldsa65() {
+  saving.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
+    if (msg?.success) {
+      inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
+      inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+
+function clearMldsa65() {
+  if (!inbound.value?.stream?.reality) return;
+  inbound.value.stream.reality.mldsa65Seed = '';
+  inbound.value.stream.reality.settings.mldsa65Verify = '';
+}
+
+// Reality target/SNI randomizer — only available if the helper is loaded
+function randomizeRealityTarget() {
+  if (!inbound.value?.stream?.reality) return;
+  if (typeof window.getRandomRealityTarget !== 'function') return;
+  const t = window.getRandomRealityTarget();
+  inbound.value.stream.reality.target = t.target;
+  inbound.value.stream.reality.serverNames = t.sni;
+}
+
+function randomizeShortIds() {
+  if (!inbound.value?.stream?.reality) return;
+  inbound.value.stream.reality.shortIds = RandomUtil.randomShortIds();
+}
+
+// === ECH cert helpers ================================================
+async function getNewEchCert() {
+  if (!inbound.value?.stream?.tls) return;
+  saving.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
+      sni: inbound.value.stream.tls.sni,
+    });
+    if (msg?.success) {
+      inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
+      inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+
+function clearEchCert() {
+  if (!inbound.value?.stream?.tls) return;
+  inbound.value.stream.tls.echServerKeys = '';
+  inbound.value.stream.tls.settings.echConfigList = '';
+}
+
+function setDefaultCertData(idx) {
+  if (!inbound.value?.stream?.tls?.certs?.[idx]) return;
+  inbound.value.stream.tls.certs[idx].certFile = defaultCert.value;
+  inbound.value.stream.tls.certs[idx].keyFile = defaultKey.value;
+}
+
+async function fetchDefaultCertSettings() {
+  try {
+    const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+    if (msg?.success && msg.obj) {
+      defaultCert.value = msg.obj.defaultCert || '';
+      defaultKey.value = msg.obj.defaultKey || '';
+    }
+  } catch (_e) { /* non-fatal — leave Set Default disabled */ }
+}
+
+// === VLESS encryption helpers =======================================
+// `xray vlessenc` returns both X25519 and ML-KEM-768 variants every
+// call; the user clicks one of two buttons to pick which block goes
+// into decryption/encryption.
+async function getNewVlessEnc(authLabel) {
+  if (!authLabel || !inbound.value?.settings) return;
+  saving.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
+    if (!msg?.success) return;
+    const block = (msg.obj?.auths || []).find((a) => a.label === authLabel);
+    if (!block) return;
+    inbound.value.settings.decryption = block.decryption;
+    inbound.value.settings.encryption = block.encryption;
+  } finally {
+    saving.value = false;
+  }
+}
+
+function clearVlessEnc() {
+  if (!inbound.value?.settings) return;
+  inbound.value.settings.decryption = 'none';
+  inbound.value.settings.encryption = 'none';
+}
+
+// === SS method change tracks legacy semantics =========================
+function onSSMethodChange() {
+  inbound.value.settings.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
+  if (inbound.value.isSSMultiUser) {
+    if (inbound.value.settings.shadowsockses.length === 0) {
+      inbound.value.settings.shadowsockses = [new Inbound.ShadowsocksSettings.Shadowsocks()];
+    }
+    inbound.value.settings.shadowsockses.forEach((c) => {
+      c.method = inbound.value.isSS2022 ? '' : inbound.value.settings.method;
+      c.password = RandomUtil.randomShadowsocksPassword(inbound.value.settings.method);
+    });
+  } else {
+    inbound.value.settings.shadowsockses = [];
+  }
+}
+
+// === Submit ==========================================================
+function close() {
+  emit('update:open', false);
+}
+
+async function submit() {
+  if (!inbound.value || !dbForm.value) return;
+  saving.value = true;
+  try {
+    // Sniffing tab is structured; stream stays JSON for unsupported
+    // transports — both go to wire as serialized JSON.
+    let streamSettings;
+    let sniffing;
+    let settings;
+    try {
+      streamSettings = canEnableStream.value
+        ? JSON.stringify(JSON.parse(advancedJson.value.stream))
+        : (inbound.value.stream?.sockopt
+          ? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
+          : '');
+    } catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
+    try {
+      sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString()));
+    } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
+    try {
+      settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString()));
+    } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
+
+    // The structured form mutates `inbound.stream` directly when the
+    // user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
+    // the Advanced JSON tab their edits live there. Keep the JSON tab
+    // authoritative — it was populated from the live model on open
+    // and watch handlers below sync in either direction.
+    const payload = {
+      up: dbForm.value.up || 0,
+      down: dbForm.value.down || 0,
+      total: dbForm.value.total,
+      remark: dbForm.value.remark,
+      enable: dbForm.value.enable,
+      expiryTime: dbForm.value.expiryTime,
+      trafficReset: dbForm.value.trafficReset,
+      lastTrafficResetTime: dbForm.value.lastTrafficResetTime || 0,
+      listen: inbound.value.listen,
+      port: inbound.value.port,
+      protocol: inbound.value.protocol,
+      settings: settings,
+      streamSettings: streamSettings,
+      sniffing: sniffing,
+    };
+    // Multi-node deployment: only include nodeId when the user picked a
+    // remote node. Sending nodeId=null over qs.stringify becomes an
+    // empty form value, which Go's form binding for *int parses as 0
+    // — not nil — and we'd then try to look up node id 0 and fail with
+    // "record not found". Omitting the key entirely keeps NodeID nil.
+    if (dbForm.value.nodeId != null) {
+      payload.nodeId = dbForm.value.nodeId;
+    }
+
+    const url = props.mode === 'edit'
+      ? `/panel/api/inbounds/update/${props.dbInbound.id}`
+      : '/panel/api/inbounds/add';
+    const msg = await HttpUtil.post(url, payload);
+    if (msg?.success) {
+      emit('saved');
+      close();
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+
+const title = computed(() =>
+  props.mode === 'edit'
+    ? t('pages.inbounds.modifyInbound')
+    : t('pages.inbounds.addInbound'),
+);
+const okText = computed(() =>
+  props.mode === 'edit' ? t('pages.client.submitEdit') : t('create'),
+);
+
+// Whenever the structured form mutates stream / sniffing / settings,
+// refresh the matching slice of the Advanced JSON tab so the user
+// always sees the live state — flipping a switch in Sniffing or
+// editing encryption in Protocol now reflects in Advanced.
+watch(
+  () => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}),
+  () => {
+    if (!inbound.value?.stream) return;
+    try {
+      advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
+    } catch (_e) { /* leave as is */ }
+  },
+);
+watch(
+  () => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}),
+  () => {
+    if (!inbound.value?.sniffing) return;
+    try {
+      advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
+    } catch (_e) { /* leave as is */ }
+  },
+);
+watch(
+  () => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}),
+  () => {
+    if (!inbound.value?.settings) return;
+    try {
+      advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
+    } catch (_e) { /* leave as is */ }
+  },
+);
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :confirm-loading="saving"
+    :mask-closable="false" width="780px" @ok="submit" @cancel="close">
+    <a-tabs v-if="inbound && dbForm" default-active-key="basic">
+      <!-- ============================== BASICS ============================== -->
+      <a-tab-pane key="basic" :tab="t('pages.xray.basicTemplate')">
+        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+          <a-form-item :label="t('enable')">
+            <a-switch v-model:checked="dbForm.enable" />
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.remark')">
+            <a-input v-model:value="dbForm.remark" />
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.deployTo')">
+            <a-select
+              v-model:value="dbForm.nodeId"
+              :disabled="mode === 'edit'"
+              :placeholder="t('pages.inbounds.localPanel')"
+              allow-clear
+            >
+              <a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
+              <a-select-option
+                v-for="n in selectableNodes"
+                :key="n.id"
+                :value="n.id"
+                :disabled="n.status === 'offline'"
+              >
+                {{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }}
+              </a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.protocol')">
+            <a-select :value="protocol" :disabled="mode === 'edit'" @change="onProtocolChange">
+              <a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.address')">
+            <a-input v-model:value="inbound.listen" :placeholder="t('pages.inbounds.monitorDesc')" />
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.port')">
+            <a-input-number v-model:value="inbound.port" :min="1" :max="65535" />
+          </a-form-item>
+          <a-form-item>
+            <template #label>
+              <a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
+            </template>
+            <a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
+          </a-form-item>
+          <a-form-item :label="t('pages.inbounds.periodicTrafficResetTitle')">
+            <a-select v-model:value="dbForm.trafficReset">
+              <a-select-option v-for="r in TRAFFIC_RESETS" :key="r" :value="r">
+                {{ t(`pages.inbounds.periodicTrafficReset.${r}`) }}
+              </a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item>
+            <template #label>
+              <a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
+              }}</a-tooltip>
+            </template>
+            <DateTimePicker v-model:value="expiryDate" />
+          </a-form-item>
+        </a-form>
+      </a-tab-pane>
+
+      <!-- ============================== PROTOCOL ============================== -->
+      <!-- TUN has no per-protocol form yet (interface/mtu/gateway live in
+           settings JSON), so the tab would render empty — hide it until
+           a TUN form is added. -->
+      <a-tab-pane v-if="protocol !== Protocols.TUN" key="protocol" :tab="t('pages.inbounds.protocol')">
+        <!-- Multi-user inbounds: in add mode embed the first client form,
+             in edit mode show a count summary. -->
+        <template v-if="isMultiUser">
+          <a-collapse v-if="mode === 'add' && firstClient" default-active-key="0">
+            <a-collapse-panel key="0" header="Client">
+              <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+                <a-form-item label="Enable">
+                  <a-switch v-model:checked="firstClient.enable" />
+                </a-form-item>
+                <a-form-item>
+                  <template #label>
+                    <a-tooltip title="Friendly identifier">
+                      Email
+                      <SyncOutlined class="random-icon" @click="randomEmail(firstClient)" />
+                    </a-tooltip>
+                  </template>
+                  <a-input v-model:value="firstClient.email" />
+                </a-form-item>
+
+                <a-form-item v-if="protocol === Protocols.VMESS || protocol === Protocols.VLESS">
+                  <template #label>
+                    <a-tooltip title="Reset to a fresh UUID">
+                      ID
+                      <SyncOutlined class="random-icon" @click="randomUuid(firstClient)" />
+                    </a-tooltip>
+                  </template>
+                  <a-input v-model:value="firstClient.id" />
+                </a-form-item>
+
+                <a-form-item v-if="protocol === Protocols.VMESS" label="Security">
+                  <a-select v-model:value="firstClient.security">
+                    <a-select-option v-for="k in SECURITY_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+                  </a-select>
+                </a-form-item>
+
+                <a-form-item v-if="protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS">
+                  <template #label>
+                    <a-tooltip title="Reset to a fresh random value">
+                      Password
+                      <SyncOutlined v-if="protocol === Protocols.SHADOWSOCKS" class="random-icon"
+                        @click="randomSSPassword(firstClient)" />
+                      <SyncOutlined v-else class="random-icon" @click="randomPasswordSeq(firstClient)" />
+                    </a-tooltip>
+                  </template>
+                  <a-input v-model:value="firstClient.password" />
+                </a-form-item>
+
+                <a-form-item v-if="protocol === Protocols.HYSTERIA">
+                  <template #label>
+                    <a-tooltip title="Reset"><span>Auth password</span>
+                      <SyncOutlined class="random-icon" @click="randomAuth(firstClient)" />
+                    </a-tooltip>
+                  </template>
+                  <a-input v-model:value="firstClient.auth" />
+                </a-form-item>
+
+                <a-form-item v-if="canEnableTlsFlow" label="Flow">
+                  <a-select v-model:value="firstClient.flow">
+                    <a-select-option value="">none</a-select-option>
+                    <a-select-option v-for="k in FLOW_OPTIONS" :key="k" :value="k">{{ k }}</a-select-option>
+                  </a-select>
+                </a-form-item>
+
+                <a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
+                  <a-input v-model:value="firstClient.reverseTag" placeholder="Optional reverse tag" />
+                </a-form-item>
+
+                <a-form-item label="Subscription">
+                  <a-input v-model:value="firstClient.subId">
+                    <template #addonAfter>
+                      <SyncOutlined class="random-icon" @click="randomSubId(firstClient)" />
+                    </template>
+                  </a-input>
+                </a-form-item>
+
+                <a-form-item label="Comment">
+                  <a-input v-model:value="firstClient.comment" />
+                </a-form-item>
+
+                <a-form-item label="Total traffic (GB)">
+                  <a-input-number v-model:value="clientTotalGB" :min="0" :step="0.1" />
+                </a-form-item>
+
+                <a-form-item label="Expiry">
+                  <DateTimePicker v-model:value="clientExpiryDate" />
+                </a-form-item>
+              </a-form>
+            </a-collapse-panel>
+          </a-collapse>
+
+          <a-collapse v-else>
+            <a-collapse-panel key="summary" :header="`Clients: ${clientsArray.length}`">
+              <table class="client-summary">
+                <thead>
+                  <tr>
+                    <th>Email</th>
+                    <th>{{ protocol === Protocols.TROJAN || protocol === Protocols.SHADOWSOCKS ? 'Password' : (protocol
+                      ===
+                      Protocols.HYSTERIA ? 'Auth' : 'ID') }}</th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="(c, idx) in clientsArray" :key="idx">
+                    <td>{{ c.email }}</td>
+                    <td>{{ c.id || c.password || c.auth }}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </a-collapse-panel>
+          </a-collapse>
+        </template>
+
+        <!-- VLess decryption / encryption -->
+        <a-form v-if="protocol === Protocols.VLESS" :colon="false" :label-col="{ md: { span: 8 } }"
+          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          <a-form-item label="Decryption">
+            <a-input v-model:value="inbound.settings.decryption" />
+          </a-form-item>
+          <a-form-item label="Encryption">
+            <a-input v-model:value="inbound.settings.encryption" />
+          </a-form-item>
+          <a-form-item label=" ">
+            <a-space :size="8" wrap>
+              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('X25519, not Post-Quantum')">
+                X25519
+              </a-button>
+              <a-button type="primary" :loading="saving" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
+                ML-KEM-768
+              </a-button>
+              <a-button danger @click="clearVlessEnc">Clear</a-button>
+            </a-space>
+          </a-form-item>
+        </a-form>
+
+        <!-- Shadowsocks shared fields (method/network/ivCheck) -->
+        <a-form v-if="protocol === Protocols.SHADOWSOCKS" :colon="false" :label-col="{ md: { span: 8 } }"
+          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          <a-form-item label="Encryption method">
+            <a-select v-model:value="inbound.settings.method" @change="onSSMethodChange">
+              <a-select-option v-for="(m, k) in SSMethods" :key="k" :value="m">{{ k }}</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item v-if="inbound.isSS2022">
+            <template #label>
+              Password
+              <SyncOutlined class="random-icon" @click="randomSSPassword(inbound.settings)" />
+            </template>
+            <a-input v-model:value="inbound.settings.password" />
+          </a-form-item>
+          <a-form-item label="Network">
+            <a-select v-model:value="inbound.settings.network" :style="{ width: '120px' }">
+              <a-select-option value="tcp,udp">TCP, UDP</a-select-option>
+              <a-select-option value="tcp">TCP</a-select-option>
+              <a-select-option value="udp">UDP</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="ivCheck">
+            <a-switch v-model:checked="inbound.settings.ivCheck" />
+          </a-form-item>
+        </a-form>
+
+        <!-- HTTP / Mixed accounts -->
+        <a-form v-if="protocol === Protocols.HTTP || protocol === Protocols.MIXED" :colon="false"
+          :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          <a-form-item label="Accounts">
+            <a-button size="small" @click="protocol === Protocols.HTTP
+              ? inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())
+              : inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+              Add
+            </a-button>
+          </a-form-item>
+          <a-form-item :wrapper-col="{ span: 24 }">
+            <a-input-group v-for="(account, idx) in inbound.settings.accounts" :key="idx" compact class="mb-8">
+              <a-input :style="{ width: '45%' }" v-model:value="account.user" placeholder="Username">
+                <template #addonBefore>{{ idx + 1 }}</template>
+              </a-input>
+              <a-input :style="{ width: '45%' }" v-model:value="account.pass" placeholder="Password" />
+              <a-button @click="inbound.settings.delAccount(idx)">
+                <template #icon>
+                  <MinusOutlined />
+                </template>
+              </a-button>
+            </a-input-group>
+          </a-form-item>
+          <a-form-item v-if="protocol === Protocols.HTTP" label="Allow transparent">
+            <a-switch v-model:checked="inbound.settings.allowTransparent" />
+          </a-form-item>
+          <template v-if="protocol === Protocols.MIXED">
+            <a-form-item label="Auth">
+              <a-select v-model:value="inbound.settings.auth">
+                <a-select-option value="noauth">noauth</a-select-option>
+                <a-select-option value="password">password</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="UDP">
+              <a-switch v-model:checked="inbound.settings.udp" />
+            </a-form-item>
+            <a-form-item v-if="inbound.settings.udp" label="UDP IP">
+              <a-input v-model:value="inbound.settings.ip" />
+            </a-form-item>
+          </template>
+        </a-form>
+
+        <!-- Tunnel -->
+        <a-form v-if="protocol === Protocols.TUNNEL" :colon="false" :label-col="{ md: { span: 8 } }"
+          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          <a-form-item label="Address">
+            <a-input v-model:value="inbound.settings.address" />
+          </a-form-item>
+          <a-form-item label="Destination port">
+            <a-input-number v-model:value="inbound.settings.port" :min="1" :max="65535" />
+          </a-form-item>
+          <a-form-item label="Network">
+            <a-select v-model:value="inbound.settings.network">
+              <a-select-option value="tcp,udp">TCP, UDP</a-select-option>
+              <a-select-option value="tcp">TCP</a-select-option>
+              <a-select-option value="udp">UDP</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Follow redirect">
+            <a-switch v-model:checked="inbound.settings.followRedirect" />
+          </a-form-item>
+        </a-form>
+
+        <!-- WireGuard -->
+        <a-form v-if="protocol === Protocols.WIREGUARD" :colon="false" :label-col="{ md: { span: 8 } }"
+          :wrapper-col="{ md: { span: 14 } }" class="mt-12">
+          <a-form-item>
+            <template #label>
+              Secret key
+              <SyncOutlined class="random-icon" @click="regenInboundWg" />
+            </template>
+            <a-input v-model:value="inbound.settings.secretKey" />
+          </a-form-item>
+          <a-form-item label="Public key">
+            <a-input v-model:value="inbound.settings.pubKey" disabled />
+          </a-form-item>
+          <a-form-item label="MTU">
+            <a-input-number v-model:value="inbound.settings.mtu" />
+          </a-form-item>
+          <a-form-item label="No-kernel TUN">
+            <a-switch v-model:checked="inbound.settings.noKernelTun" />
+          </a-form-item>
+          <a-form-item label="Peers">
+            <a-button size="small" @click="inbound.settings.addPeer()">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+              Add peer
+            </a-button>
+          </a-form-item>
+          <div v-for="(peer, idx) in inbound.settings.peers" :key="idx" class="wg-peer">
+            <a-divider style="margin: 8px 0">
+              Peer {{ idx + 1 }}
+              <DeleteOutlined v-if="inbound.settings.peers.length > 1" class="danger-icon"
+                @click="inbound.settings.delPeer(idx)" />
+            </a-divider>
+            <a-form-item>
+              <template #label>
+                Secret key
+                <SyncOutlined class="random-icon" @click="regenWgKeypair(peer)" />
+              </template>
+              <a-input v-model:value="peer.privateKey" />
+            </a-form-item>
+            <a-form-item label="Public key">
+              <a-input v-model:value="peer.publicKey" />
+            </a-form-item>
+            <a-form-item label="PSK">
+              <a-input v-model:value="peer.psk" />
+            </a-form-item>
+            <a-form-item label="Allowed IPs">
+              <a-button size="small" @click="peer.allowedIPs.push('')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+              <a-input v-for="(_ip, j) in peer.allowedIPs" :key="j" v-model:value="peer.allowedIPs[j]" class="mt-4">
+                <template #addonAfter>
+                  <a-button v-if="peer.allowedIPs.length > 1" size="small" @click="peer.allowedIPs.splice(j, 1)">
+                    <template #icon>
+                      <MinusOutlined />
+                    </template>
+                  </a-button>
+                </template>
+              </a-input>
+            </a-form-item>
+            <a-form-item label="Keep-alive">
+              <a-input-number v-model:value="peer.keepAlive" :min="0" />
+            </a-form-item>
+          </div>
+        </a-form>
+
+        <!-- ============== Fallbacks (VLESS/Trojan over TCP) ============== -->
+        <template v-if="showFallbacks">
+          <a-divider style="margin: 12px 0" />
+          <div class="fallbacks-header">
+            <a-tooltip
+              title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport.">
+              <span class="fallbacks-title">
+                Fallbacks ({{ inbound.settings.fallbacks.length }})
+              </span>
+            </a-tooltip>
+            <a-button type="primary" size="small" @click="addFallback">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+              Add
+            </a-button>
+          </div>
+
+          <a-form v-for="(fallback, idx) in inbound.settings.fallbacks" :key="idx" :colon="false"
+            :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+            <a-divider style="margin: 0">
+              Fallback {{ idx + 1 }}
+              <DeleteOutlined class="danger-icon" @click="delFallback(idx)" />
+            </a-divider>
+
+            <a-form-item>
+              <template #label>
+                <a-tooltip title="Match TLS SNI (server name). Leave empty to match any SNI.">
+                  SNI
+                </a-tooltip>
+              </template>
+              <a-input v-model:value.trim="fallback.name" placeholder="any (leave empty)" />
+            </a-form-item>
+
+            <a-form-item>
+              <template #label>
+                <a-tooltip
+                  title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both.">
+                  ALPN
+                </a-tooltip>
+              </template>
+              <a-select v-model:value="fallback.alpn">
+                <a-select-option value="">any</a-select-option>
+                <a-select-option value="h2">h2</a-select-option>
+                <a-select-option value="http/1.1">http/1.1</a-select-option>
+              </a-select>
+            </a-form-item>
+
+            <a-form-item :validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
+              :help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''">
+              <template #label>
+                <a-tooltip
+                  title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any.">
+                  Path
+                </a-tooltip>
+              </template>
+              <a-input v-model:value.trim="fallback.path" placeholder="any (leave empty) or /ws" />
+            </a-form-item>
+
+            <a-form-item :validate-status="!fallback.dest ? 'error' : ''"
+              :help="!fallback.dest ? 'Destination is required' : ''">
+              <template #label>
+                <a-tooltip
+                  title="Where matching traffic is forwarded. Accepts a port number (80), an addr:port (127.0.0.1:8080), or a Unix socket path (/dev/shm/x.sock or @abstract).">
+                  Destination
+                </a-tooltip>
+              </template>
+              <a-input v-model:value.trim="fallback.dest" placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock" />
+            </a-form-item>
+
+            <a-form-item>
+              <template #label>
+                <a-tooltip
+                  title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it.">
+                  PROXY
+                </a-tooltip>
+              </template>
+              <a-select v-model:value="fallback.xver">
+                <a-select-option :value="0">Off</a-select-option>
+                <a-select-option :value="1">v1</a-select-option>
+                <a-select-option :value="2">v2</a-select-option>
+              </a-select>
+            </a-form-item>
+          </a-form>
+        </template>
+      </a-tab-pane>
+
+      <!-- ============================== STREAM ============================== -->
+      <a-tab-pane v-if="canEnableStream" key="stream"
+        tab="Stream"><!-- "Stream" stays literal — it's a wire-format identifier -->
+        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+          <a-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
+            <a-select v-model:value="network" :style="{ width: '75%' }">
+              <a-select-option value="tcp">TCP (RAW)</a-select-option>
+              <a-select-option value="kcp">mKCP</a-select-option>
+              <a-select-option value="ws">WebSocket</a-select-option>
+              <a-select-option value="grpc">gRPC</a-select-option>
+              <a-select-option value="httpupgrade">HTTPUpgrade</a-select-option>
+              <a-select-option value="xhttp">XHTTP</a-select-option>
+            </a-select>
+          </a-form-item>
+
+          <!-- TCP (RAW) — proxy-protocol + optional HTTP camouflage with full request/response editor -->
+          <template v-if="network === 'tcp'">
+            <a-form-item v-if="canEnableTls" label="Proxy Protocol">
+              <a-switch v-model:checked="inbound.stream.tcp.acceptProxyProtocol" />
+            </a-form-item>
+            <a-form-item :label="`HTTP ${t('camouflage')}`">
+              <a-switch :checked="inbound.stream.tcp.type === 'http'"
+                @change="(v) => (inbound.stream.tcp.type = v ? 'http' : 'none')" />
+            </a-form-item>
+
+            <template v-if="inbound.stream.tcp.type === 'http'">
+              <!-- Request -->
+              <a-divider :style="{ margin: '0' }">{{ t('pages.inbounds.stream.general.request') }}</a-divider>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.version')">
+                <a-input v-model:value="inbound.stream.tcp.request.version" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.method')">
+                <a-input v-model:value="inbound.stream.tcp.request.method" />
+              </a-form-item>
+              <a-form-item>
+                <template #label>
+                  {{ t('pages.inbounds.stream.tcp.path') }}
+                  <a-button size="small" :style="{ marginLeft: '6px' }"
+                    @click="inbound.stream.tcp.request.addPath('/')">
+                    <template #icon>
+                      <PlusOutlined />
+                    </template>
+                  </a-button>
+                </template>
+                <template v-for="(_p, idx) in inbound.stream.tcp.request.path" :key="`tcp-path-${idx}`">
+                  <a-input v-model:value="inbound.stream.tcp.request.path[idx]" class="mb-4">
+                    <template #addonAfter>
+                      <a-button v-if="inbound.stream.tcp.request.path.length > 1" size="small"
+                        @click="inbound.stream.tcp.request.removePath(idx)">
+                        <template #icon>
+                          <MinusOutlined />
+                        </template>
+                      </a-button>
+                    </template>
+                  </a-input>
+                </template>
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
+                <a-button size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')">
+                  <template #icon>
+                    <PlusOutlined />
+                  </template>
+                </a-button>
+              </a-form-item>
+              <a-form-item v-if="inbound.stream.tcp.request.headers.length > 0" :wrapper-col="{ span: 24 }">
+                <a-input-group v-for="(h, idx) in inbound.stream.tcp.request.headers" :key="`tcp-rh-${idx}`" compact
+                  class="mb-8">
+                  <a-input :style="{ width: '45%' }" v-model:value="h.name"
+                    :placeholder="t('pages.inbounds.stream.general.name')">
+                    <template #addonBefore>{{ idx + 1 }}</template>
+                  </a-input>
+                  <a-input :style="{ width: '45%' }" v-model:value="h.value"
+                    :placeholder="t('pages.inbounds.stream.general.value')" />
+                  <a-button @click="inbound.stream.tcp.request.removeHeader(idx)">
+                    <template #icon>
+                      <MinusOutlined />
+                    </template>
+                  </a-button>
+                </a-input-group>
+              </a-form-item>
+
+              <!-- Response -->
+              <a-divider :style="{ margin: '0' }">{{ t('pages.inbounds.stream.general.response') }}</a-divider>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.version')">
+                <a-input v-model:value="inbound.stream.tcp.response.version" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.status')">
+                <a-input v-model:value="inbound.stream.tcp.response.status" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.statusDescription')">
+                <a-input v-model:value="inbound.stream.tcp.response.reason" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.stream.tcp.responseHeader')">
+                <a-button size="small"
+                  @click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')">
+                  <template #icon>
+                    <PlusOutlined />
+                  </template>
+                </a-button>
+              </a-form-item>
+              <a-form-item v-if="inbound.stream.tcp.response.headers.length > 0" :wrapper-col="{ span: 24 }">
+                <a-input-group v-for="(h, idx) in inbound.stream.tcp.response.headers" :key="`tcp-rsh-${idx}`" compact
+                  class="mb-8">
+                  <a-input :style="{ width: '45%' }" v-model:value="h.name"
+                    :placeholder="t('pages.inbounds.stream.general.name')">
+                    <template #addonBefore>{{ idx + 1 }}</template>
+                  </a-input>
+                  <a-input :style="{ width: '45%' }" v-model:value="h.value"
+                    :placeholder="t('pages.inbounds.stream.general.value')" />
+                  <a-button @click="inbound.stream.tcp.response.removeHeader(idx)">
+                    <template #icon>
+                      <MinusOutlined />
+                    </template>
+                  </a-button>
+                </a-input-group>
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- mKCP -->
+          <template v-if="network === 'kcp'">
+            <a-form-item label="MTU">
+              <a-input-number v-model:value="inbound.stream.kcp.mtu" :min="576" :max="1460" />
+            </a-form-item>
+            <a-form-item label="TTI (ms)">
+              <a-input-number v-model:value="inbound.stream.kcp.tti" :min="10" :max="100" />
+            </a-form-item>
+            <a-form-item label="Uplink (MB/s)">
+              <a-input-number v-model:value="inbound.stream.kcp.upCap" :min="0" />
+            </a-form-item>
+            <a-form-item label="Downlink (MB/s)">
+              <a-input-number v-model:value="inbound.stream.kcp.downCap" :min="0" />
+            </a-form-item>
+            <a-form-item label="CWND Multiplier">
+              <a-input-number v-model:value="inbound.stream.kcp.cwndMultiplier" :min="1" />
+            </a-form-item>
+            <a-form-item label="Max Sending Window">
+              <a-input-number v-model:value="inbound.stream.kcp.maxSendingWindow" :min="0" />
+            </a-form-item>
+          </template>
+
+          <!-- WebSocket -->
+          <template v-if="network === 'ws'">
+            <a-form-item label="Proxy Protocol">
+              <a-switch v-model:checked="inbound.stream.ws.acceptProxyProtocol" />
+            </a-form-item>
+            <a-form-item :label="t('host')">
+              <a-input v-model:value="inbound.stream.ws.host" />
+            </a-form-item>
+            <a-form-item :label="t('path')">
+              <a-input v-model:value="inbound.stream.ws.path" />
+            </a-form-item>
+            <a-form-item label="Heartbeat Period">
+              <a-input-number v-model:value="inbound.stream.ws.heartbeatPeriod" :min="0" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
+              <a-button size="small" @click="inbound.stream.ws.addHeader('', '')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.ws.headers.length > 0" :wrapper-col="{ span: 24 }">
+              <a-input-group v-for="(h, idx) in inbound.stream.ws.headers" :key="`ws-h-${idx}`" compact class="mb-8">
+                <a-input :style="{ width: '45%' }" v-model:value="h.name"
+                  :placeholder="t('pages.inbounds.stream.general.name')">
+                  <template #addonBefore>{{ idx + 1 }}</template>
+                </a-input>
+                <a-input :style="{ width: '45%' }" v-model:value="h.value"
+                  :placeholder="t('pages.inbounds.stream.general.value')" />
+                <a-button @click="inbound.stream.ws.removeHeader(idx)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </a-input-group>
+            </a-form-item>
+          </template>
+
+          <!-- gRPC -->
+          <template v-if="network === 'grpc'">
+            <a-form-item label="Service Name">
+              <a-input v-model:value="inbound.stream.grpc.serviceName" />
+            </a-form-item>
+            <a-form-item label="Authority">
+              <a-input v-model:value="inbound.stream.grpc.authority" />
+            </a-form-item>
+            <a-form-item label="Multi Mode">
+              <a-switch v-model:checked="inbound.stream.grpc.multiMode" />
+            </a-form-item>
+          </template>
+
+          <!-- HTTPUpgrade -->
+          <template v-if="network === 'httpupgrade'">
+            <a-form-item label="Proxy Protocol">
+              <a-switch v-model:checked="inbound.stream.httpupgrade.acceptProxyProtocol" />
+            </a-form-item>
+            <a-form-item :label="t('host')">
+              <a-input v-model:value="inbound.stream.httpupgrade.host" />
+            </a-form-item>
+            <a-form-item :label="t('path')">
+              <a-input v-model:value="inbound.stream.httpupgrade.path" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
+              <a-button size="small" @click="inbound.stream.httpupgrade.addHeader('', '')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.httpupgrade.headers.length > 0" :wrapper-col="{ span: 24 }">
+              <a-input-group v-for="(h, idx) in inbound.stream.httpupgrade.headers" :key="`hu-h-${idx}`" compact
+                class="mb-8">
+                <a-input :style="{ width: '45%' }" v-model:value="h.name"
+                  :placeholder="t('pages.inbounds.stream.general.name')">
+                  <template #addonBefore>{{ idx + 1 }}</template>
+                </a-input>
+                <a-input :style="{ width: '45%' }" v-model:value="h.value"
+                  :placeholder="t('pages.inbounds.stream.general.value')" />
+                <a-button @click="inbound.stream.httpupgrade.removeHeader(idx)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </a-input-group>
+            </a-form-item>
+          </template>
+
+          <!-- XHTTP -->
+          <template v-if="network === 'xhttp'">
+            <a-form-item :label="t('host')">
+              <a-input v-model:value="inbound.stream.xhttp.host" />
+            </a-form-item>
+            <a-form-item :label="t('path')">
+              <a-input v-model:value="inbound.stream.xhttp.path" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
+              <a-button size="small" @click="inbound.stream.xhttp.addHeader('', '')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.headers.length > 0" :wrapper-col="{ span: 24 }">
+              <a-input-group v-for="(h, idx) in inbound.stream.xhttp.headers" :key="`xh-h-${idx}`" compact class="mb-8">
+                <a-input :style="{ width: '45%' }" v-model:value="h.name"
+                  :placeholder="t('pages.inbounds.stream.general.name')">
+                  <template #addonBefore>{{ idx + 1 }}</template>
+                </a-input>
+                <a-input :style="{ width: '45%' }" v-model:value="h.value"
+                  :placeholder="t('pages.inbounds.stream.general.value')" />
+                <a-button @click="inbound.stream.xhttp.removeHeader(idx)">
+                  <template #icon>
+                    <MinusOutlined />
+                  </template>
+                </a-button>
+              </a-input-group>
+            </a-form-item>
+            <a-form-item label="Mode">
+              <a-select v-model:value="inbound.stream.xhttp.mode" :style="{ width: '50%' }">
+                <a-select-option v-for="m in MODE_OPTIONS" :key="m" :value="m">{{ m }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Max Buffered Upload">
+              <a-input-number v-model:value="inbound.stream.xhttp.scMaxBufferedPosts" />
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Max Upload Size (Byte)">
+              <a-input v-model:value="inbound.stream.xhttp.scMaxEachPostBytes" />
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.mode === 'stream-up'" label="Stream-Up Server">
+              <a-input v-model:value="inbound.stream.xhttp.scStreamUpServerSecs" />
+            </a-form-item>
+            <a-form-item label="Server Max Header Bytes">
+              <a-input-number v-model:value="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
+                placeholder="0 (default)" />
+            </a-form-item>
+            <a-form-item label="Padding Bytes">
+              <a-input v-model:value="inbound.stream.xhttp.xPaddingBytes" />
+            </a-form-item>
+            <a-form-item label="Padding Obfs Mode">
+              <a-switch v-model:checked="inbound.stream.xhttp.xPaddingObfsMode" />
+            </a-form-item>
+            <template v-if="inbound.stream.xhttp.xPaddingObfsMode">
+              <a-form-item label="Padding Key">
+                <a-input v-model:value="inbound.stream.xhttp.xPaddingKey" placeholder="x_padding" />
+              </a-form-item>
+              <a-form-item label="Padding Header">
+                <a-input v-model:value="inbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding" />
+              </a-form-item>
+              <a-form-item label="Padding Placement">
+                <a-select v-model:value="inbound.stream.xhttp.xPaddingPlacement">
+                  <a-select-option value="">Default (queryInHeader)</a-select-option>
+                  <a-select-option value="queryInHeader">queryInHeader</a-select-option>
+                  <a-select-option value="header">header</a-select-option>
+                  <a-select-option value="cookie">cookie</a-select-option>
+                  <a-select-option value="query">query</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Padding Method">
+                <a-select v-model:value="inbound.stream.xhttp.xPaddingMethod">
+                  <a-select-option value="">Default (repeat-x)</a-select-option>
+                  <a-select-option value="repeat-x">repeat-x</a-select-option>
+                  <a-select-option value="tokenish">tokenish</a-select-option>
+                </a-select>
+              </a-form-item>
+            </template>
+            <a-form-item label="Session Placement">
+              <a-select v-model:value="inbound.stream.xhttp.sessionPlacement">
+                <a-select-option value="">Default (path)</a-select-option>
+                <a-select-option value="path">path</a-select-option>
+                <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item
+              v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
+              label="Session Key">
+              <a-input v-model:value="inbound.stream.xhttp.sessionKey" placeholder="x_session" />
+            </a-form-item>
+            <a-form-item label="Sequence Placement">
+              <a-select v-model:value="inbound.stream.xhttp.seqPlacement">
+                <a-select-option value="">Default (path)</a-select-option>
+                <a-select-option value="path">path</a-select-option>
+                <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
+              label="Sequence Key">
+              <a-input v-model:value="inbound.stream.xhttp.seqKey" placeholder="x_seq" />
+            </a-form-item>
+            <a-form-item v-if="inbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
+              <a-select v-model:value="inbound.stream.xhttp.uplinkDataPlacement">
+                <a-select-option value="">Default (body)</a-select-option>
+                <a-select-option value="body">body</a-select-option>
+                <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item
+              v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
+              label="Uplink Data Key">
+              <a-input v-model:value="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
+            </a-form-item>
+            <a-form-item label="No SSE Header">
+              <a-switch v-model:checked="inbound.stream.xhttp.noSSEHeader" />
+            </a-form-item>
+          </template>
+
+          <!-- ====== Security section ====== -->
+          <a-form-item label="Security">
+            <a-select v-model:value="security" :style="{ width: '160px' }" :disabled="!canEnableTls">
+              <a-select-option value="none">none</a-select-option>
+              <a-select-option value="tls">tls</a-select-option>
+              <a-select-option v-if="canEnableReality" value="reality">reality</a-select-option>
+            </a-select>
+          </a-form-item>
+
+          <template v-if="security === 'tls' && inbound.stream.tls">
+            <a-form-item label="SNI">
+              <a-input v-model:value="inbound.stream.tls.sni" placeholder="Server Name Indication" />
+            </a-form-item>
+            <a-form-item label="Cipher Suites">
+              <a-select v-model:value="inbound.stream.tls.cipherSuites">
+                <a-select-option value="">Auto</a-select-option>
+                <a-select-option v-for="[label, val] in CIPHER_SUITES" :key="val" :value="val">{{ label
+                }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Min/Max Version">
+              <a-input-group compact>
+                <a-select v-model:value="inbound.stream.tls.minVersion" :style="{ width: '50%' }">
+                  <a-select-option v-for="v in TLS_VERSIONS" :key="v" :value="v">{{ v }}</a-select-option>
+                </a-select>
+                <a-select v-model:value="inbound.stream.tls.maxVersion" :style="{ width: '50%' }">
+                  <a-select-option v-for="v in TLS_VERSIONS" :key="v" :value="v">{{ v }}</a-select-option>
+                </a-select>
+              </a-input-group>
+            </a-form-item>
+            <a-form-item label="uTLS">
+              <a-select v-model:value="inbound.stream.tls.settings.fingerprint" :style="{ width: '100%' }">
+                <a-select-option value="">None</a-select-option>
+                <a-select-option v-for="fp in FINGERPRINTS" :key="fp" :value="fp">{{ fp }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="ALPN">
+              <a-select v-model:value="inbound.stream.tls.alpn" mode="multiple" :style="{ width: '100%' }"
+                :token-separators="[',']">
+                <a-select-option v-for="a in ALPNS" :key="a" :value="a">{{ a }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Reject Unknown SNI">
+              <a-switch v-model:checked="inbound.stream.tls.rejectUnknownSni" />
+            </a-form-item>
+            <a-form-item label="Disable System Root">
+              <a-switch v-model:checked="inbound.stream.tls.disableSystemRoot" />
+            </a-form-item>
+            <a-form-item label="Session Resumption">
+              <a-switch v-model:checked="inbound.stream.tls.enableSessionResumption" />
+            </a-form-item>
+
+
+            <!-- Cert array — file path or inline content per row -->
+            <template v-for="(cert, idx) in inbound.stream.tls.certs" :key="`cert-${idx}`">
+              <a-form-item :label="t('certificate')">
+                <a-radio-group v-model:value="cert.useFile" button-style="solid">
+                  <a-radio-button :value="true">{{ t('pages.inbounds.certificatePath') }}</a-radio-button>
+                  <a-radio-button :value="false">{{ t('pages.inbounds.certificateContent') }}</a-radio-button>
+                </a-radio-group>
+              </a-form-item>
+              <a-form-item label=" ">
+                <a-space>
+                  <a-button v-if="idx === 0" type="primary" size="small" @click="inbound.stream.tls.addCert()">
+                    <template #icon>
+                      <PlusOutlined />
+                    </template>
+                  </a-button>
+                  <a-button v-if="inbound.stream.tls.certs.length > 1" type="primary" size="small"
+                    @click="inbound.stream.tls.removeCert(idx)">
+                    <template #icon>
+                      <MinusOutlined />
+                    </template>
+                  </a-button>
+                </a-space>
+              </a-form-item>
+              <template v-if="cert.useFile">
+                <a-form-item :label="t('pages.inbounds.publicKey')">
+                  <a-input v-model:value="cert.certFile" />
+                </a-form-item>
+                <a-form-item :label="t('pages.inbounds.privatekey')">
+                  <a-input v-model:value="cert.keyFile" />
+                </a-form-item>
+                <a-form-item label=" ">
+                  <a-button type="primary" :disabled="!defaultCert && !defaultKey" @click="setDefaultCertData(idx)">
+                    {{ t('pages.inbounds.setDefaultCert') }}
+                  </a-button>
+                </a-form-item>
+              </template>
+              <template v-else>
+                <a-form-item :label="t('pages.inbounds.publicKey')">
+                  <a-textarea v-model:value="cert.cert" :auto-size="{ minRows: 3, maxRows: 8 }" />
+                </a-form-item>
+                <a-form-item :label="t('pages.inbounds.privatekey')">
+                  <a-textarea v-model:value="cert.key" :auto-size="{ minRows: 3, maxRows: 8 }" />
+                </a-form-item>
+              </template>
+              <a-form-item label="One Time Loading">
+                <a-switch v-model:checked="cert.oneTimeLoading" />
+              </a-form-item>
+              <a-form-item label="Usage Option">
+                <a-select v-model:value="cert.usage" :style="{ width: '50%' }">
+                  <a-select-option v-for="u in USAGES" :key="u" :value="u">{{ u }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item v-if="cert.usage === 'issue'" label="Build Chain">
+                <a-switch v-model:checked="cert.buildChain" />
+              </a-form-item>
+            </template>
+
+
+            <!-- ECH (Encrypted Client Hello) -->
+            <a-form-item label="ECH key">
+              <a-input v-model:value="inbound.stream.tls.echServerKeys" />
+            </a-form-item>
+            <a-form-item label="ECH config">
+              <a-input v-model:value="inbound.stream.tls.settings.echConfigList" />
+            </a-form-item>
+            <a-form-item label=" ">
+              <a-space>
+                <a-button type="primary" :loading="saving" @click="getNewEchCert">Get New ECH Cert</a-button>
+                <a-button danger @click="clearEchCert">Clear</a-button>
+              </a-space>
+            </a-form-item>
+          </template>
+
+          <template v-if="security === 'reality' && inbound.stream.reality">
+            <a-form-item label="Show">
+              <a-switch v-model:checked="inbound.stream.reality.show" />
+            </a-form-item>
+            <a-form-item label="Xver">
+              <a-input-number v-model:value="inbound.stream.reality.xver" :min="0" />
+            </a-form-item>
+            <a-form-item label="uTLS">
+              <a-select v-model:value="inbound.stream.reality.settings.fingerprint" :style="{ width: '100%' }">
+                <a-select-option v-for="fp in FINGERPRINTS" :key="fp" :value="fp">{{ fp }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                Target
+                <SyncOutlined class="random-icon" @click="randomizeRealityTarget" />
+              </template>
+              <a-input v-model:value="inbound.stream.reality.target" />
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                SNI
+                <SyncOutlined class="random-icon" @click="randomizeRealityTarget" />
+              </template>
+              <a-input v-model:value="inbound.stream.reality.serverNames" />
+            </a-form-item>
+            <a-form-item label="Max Time Diff (ms)">
+              <a-input-number v-model:value="inbound.stream.reality.maxTimediff" :min="0" />
+            </a-form-item>
+            <a-form-item label="Min Client Ver">
+              <a-input v-model:value="inbound.stream.reality.minClientVer" placeholder="25.9.11" />
+            </a-form-item>
+            <a-form-item label="Max Client Ver">
+              <a-input v-model:value="inbound.stream.reality.maxClientVer" placeholder="25.9.11" />
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                Short IDs
+                <SyncOutlined class="random-icon" @click="randomizeShortIds" />
+              </template>
+              <a-textarea v-model:value="inbound.stream.reality.shortIds" :auto-size="{ minRows: 1, maxRows: 4 }" />
+            </a-form-item>
+            <a-form-item label="SpiderX">
+              <a-input v-model:value="inbound.stream.reality.settings.spiderX" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.publicKey')">
+              <a-textarea v-model:value="inbound.stream.reality.settings.publicKey"
+                :auto-size="{ minRows: 1, maxRows: 4 }" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.privatekey')">
+              <a-textarea v-model:value="inbound.stream.reality.privateKey" :auto-size="{ minRows: 1, maxRows: 4 }" />
+            </a-form-item>
+            <a-form-item label=" ">
+              <a-space>
+                <a-button type="primary" :loading="saving" @click="genRealityKeypair">Get New Cert</a-button>
+                <a-button danger @click="clearRealityKeypair">Clear</a-button>
+              </a-space>
+            </a-form-item>
+            <a-form-item label="mldsa65 Seed">
+              <a-textarea v-model:value="inbound.stream.reality.mldsa65Seed" :auto-size="{ minRows: 2, maxRows: 6 }" />
+            </a-form-item>
+            <a-form-item label="mldsa65 Verify">
+              <a-textarea v-model:value="inbound.stream.reality.settings.mldsa65Verify"
+                :auto-size="{ minRows: 2, maxRows: 6 }" />
+            </a-form-item>
+            <a-form-item label=" ">
+              <a-space>
+                <a-button type="primary" :loading="saving" @click="genMldsa65">Get New Seed</a-button>
+                <a-button danger @click="clearMldsa65">Clear</a-button>
+              </a-space>
+            </a-form-item>
+          </template>
+
+          <!-- ====== External Proxy ====== -->
+          <a-form-item label="External Proxy">
+            <a-switch v-model:checked="externalProxy" />
+            <a-button v-if="externalProxy" size="small" type="primary" :style="{ marginLeft: '10px' }"
+              @click="inbound.stream.externalProxy.push({ forceTls: 'same', dest: '', port: 443, remark: '' })">
+              <template #icon>
+                <PlusOutlined />
+              </template>
+            </a-button>
+          </a-form-item>
+          <a-form-item v-if="externalProxy" :wrapper-col="{ span: 24 }">
+            <a-input-group v-for="(row, idx) in inbound.stream.externalProxy" :key="`ep-${idx}`" compact
+              :style="{ margin: '8px 0' }">
+              <a-tooltip title="Force TLS">
+                <a-select v-model:value="row.forceTls" :style="{ width: '20%' }">
+                  <a-select-option value="same">{{ t('pages.inbounds.same') }}</a-select-option>
+                  <a-select-option value="none">{{ t('none') }}</a-select-option>
+                  <a-select-option value="tls">TLS</a-select-option>
+                </a-select>
+              </a-tooltip>
+              <a-input v-model:value="row.dest" :style="{ width: '30%' }" :placeholder="t('host')" />
+              <a-tooltip :title="t('pages.inbounds.port')">
+                <a-input-number v-model:value="row.port" :style="{ width: '15%' }" :min="1" :max="65535" />
+              </a-tooltip>
+              <a-input v-model:value="row.remark" :style="{ width: '35%' }" :placeholder="t('pages.inbounds.remark')">
+                <template #addonAfter>
+                  <MinusOutlined @click="inbound.stream.externalProxy.splice(idx, 1)" />
+                </template>
+              </a-input>
+            </a-input-group>
+          </a-form-item>
+
+          <!-- ====== Sockopt ====== -->
+          <a-form-item label="Sockopt">
+            <a-switch v-model:checked="inbound.stream.sockoptSwitch" />
+          </a-form-item>
+          <template v-if="inbound.stream.sockoptSwitch && inbound.stream.sockopt">
+            <a-form-item label="Route Mark">
+              <a-input-number v-model:value="inbound.stream.sockopt.mark" :min="0" />
+            </a-form-item>
+            <a-form-item label="TCP Keep Alive Interval">
+              <a-input-number v-model:value="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
+            </a-form-item>
+            <a-form-item label="TCP Keep Alive Idle">
+              <a-input-number v-model:value="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0" />
+            </a-form-item>
+            <a-form-item label="TCP Max Seg">
+              <a-input-number v-model:value="inbound.stream.sockopt.tcpMaxSeg" :min="0" />
+            </a-form-item>
+            <a-form-item label="TCP User Timeout">
+              <a-input-number v-model:value="inbound.stream.sockopt.tcpUserTimeout" :min="0" />
+            </a-form-item>
+            <a-form-item label="TCP Window Clamp">
+              <a-input-number v-model:value="inbound.stream.sockopt.tcpWindowClamp" :min="0" />
+            </a-form-item>
+            <a-form-item label="Proxy Protocol">
+              <a-switch v-model:checked="inbound.stream.sockopt.acceptProxyProtocol" />
+            </a-form-item>
+            <a-form-item label="TCP Fast Open">
+              <a-switch v-model:checked="inbound.stream.sockopt.tcpFastOpen" />
+            </a-form-item>
+            <a-form-item label="Multipath TCP">
+              <a-switch v-model:checked="inbound.stream.sockopt.tcpMptcp" />
+            </a-form-item>
+            <a-form-item label="Penetrate">
+              <a-switch v-model:checked="inbound.stream.sockopt.penetrate" />
+            </a-form-item>
+            <a-form-item label="V6 Only">
+              <a-switch v-model:checked="inbound.stream.sockopt.V6Only" />
+            </a-form-item>
+            <a-form-item label="Domain Strategy">
+              <a-select v-model:value="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }">
+                <a-select-option v-for="d in DOMAIN_STRATEGIES" :key="d" :value="d">{{ d }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="TCP Congestion">
+              <a-select v-model:value="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }">
+                <a-select-option v-for="c in TCP_CONGESTIONS" :key="c" :value="c">{{ c }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="TProxy">
+              <a-select v-model:value="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }">
+                <a-select-option value="off">Off</a-select-option>
+                <a-select-option value="redirect">Redirect</a-select-option>
+                <a-select-option value="tproxy">TProxy</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Dialer Proxy">
+              <a-input v-model:value="inbound.stream.sockopt.dialerProxy" />
+            </a-form-item>
+            <a-form-item label="Interface Name">
+              <a-input v-model:value="inbound.stream.sockopt.interfaceName" />
+            </a-form-item>
+            <a-form-item label="Trusted X-Forwarded-For">
+              <a-select v-model:value="inbound.stream.sockopt.trustedXForwardedFor" mode="tags"
+                :style="{ width: '100%' }" :token-separators="[',']">
+                <a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
+                <a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
+                <a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
+                <a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
+              </a-select>
+            </a-form-item>
+          </template>
+        </a-form>
+
+        <!-- ====== FinalMask (TCP/UDP masks + QUIC params) ====== -->
+        <FinalMaskForm :stream="inbound.stream" :protocol="protocol" />
+      </a-tab-pane>
+
+      <!-- ============================== SNIFFING ============================== -->
+      <a-tab-pane key="sniffing" tab="Sniffing"><!-- "Sniffing" stays literal — xray config term -->
+        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+          <a-form-item label="Enabled">
+            <a-switch v-model:checked="inbound.sniffing.enabled" />
+          </a-form-item>
+          <template v-if="inbound.sniffing.enabled">
+            <a-form-item :wrapper-col="{ span: 24 }">
+              <a-checkbox-group v-model:value="inbound.sniffing.destOverride">
+                <a-checkbox v-for="(value, key) in SNIFFING_OPTION" :key="key" :value="value">{{ key }}</a-checkbox>
+              </a-checkbox-group>
+            </a-form-item>
+            <a-form-item label="Metadata only">
+              <a-switch v-model:checked="inbound.sniffing.metadataOnly" />
+            </a-form-item>
+            <a-form-item label="Route only">
+              <a-switch v-model:checked="inbound.sniffing.routeOnly" />
+            </a-form-item>
+            <a-form-item label="IPs excluded">
+              <a-select v-model:value="inbound.sniffing.ipsExcluded" mode="tags" :token-separators="[',']"
+                placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
+            </a-form-item>
+            <a-form-item label="Domains excluded">
+              <a-select v-model:value="inbound.sniffing.domainsExcluded" mode="tags" :token-separators="[',']"
+                placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
+            </a-form-item>
+          </template>
+        </a-form>
+      </a-tab-pane>
+
+      <!-- ============================== ADVANCED ============================== -->
+      <a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
+        <a-alert type="info" show-icon
+          message="Edit raw stream JSON to access advanced fields we don't yet expose through the form."
+          class="mb-12" />
+        <a-form layout="vertical">
+          <a-form-item label="settings (clients, encryption, fallbacks, …)">
+            <a-textarea v-model:value="advancedJson.settings" :auto-size="{ minRows: 10, maxRows: 24 }"
+              spellcheck="false" class="json-editor" />
+          </a-form-item>
+          <a-form-item label="streamSettings">
+            <a-textarea v-model:value="advancedJson.stream" :auto-size="{ minRows: 10, maxRows: 24 }" spellcheck="false"
+              class="json-editor" />
+          </a-form-item>
+          <a-form-item label="sniffing (overrides the Sniffing tab when set)">
+            <a-textarea v-model:value="advancedJson.sniffing" :auto-size="{ minRows: 6, maxRows: 16 }"
+              spellcheck="false" class="json-editor" />
+          </a-form-item>
+        </a-form>
+      </a-tab-pane>
+    </a-tabs>
+  </a-modal>
+</template>
+
+<style scoped>
+.mt-4 {
+  margin-top: 4px;
+}
+
+.mt-8 {
+  margin-top: 8px;
+}
+
+.mt-12 {
+  margin-top: 12px;
+}
+
+.mb-4 {
+  margin-bottom: 4px;
+}
+
+.mb-8 {
+  margin-bottom: 8px;
+}
+
+.mb-12 {
+  margin-bottom: 12px;
+}
+
+.random-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-primary-color, #1890ff);
+}
+
+.danger-icon {
+  margin-left: 6px;
+  cursor: pointer;
+  color: #ff4d4f;
+}
+
+.json-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+
+.client-summary {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.client-summary th,
+.client-summary td {
+  padding: 4px 8px;
+  text-align: left;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.fallbacks-header {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin: 8px 0;
+}
+
+.fallbacks-title {
+  font-weight: 500;
+  flex: 1;
+}
+
+.wg-peer {
+  margin-top: 4px;
+}
+
+.section-heading {
+  font-weight: 500;
+  margin: 12px 0 6px;
+  opacity: 0.85;
+}
+</style>

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

@@ -0,0 +1,1012 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import {
+  HttpUtil,
+  IntlUtil,
+  SizeFormatter,
+  ColorUtils,
+  ClipboardManager,
+  FileManager,
+} from '@/utils';
+import { Protocols } from '@/models/inbound.js';
+import InfinityIcon from '@/components/InfinityIcon.vue';
+import { useDatepicker } from '@/composables/useDatepicker.js';
+
+const { t } = useI18n();
+const { datepicker } = useDatepicker();
+
+// One modal handles every protocol's info / share view because the
+// legacy template did the same. The big v-if forks at the top decide
+// which sub-block of the body renders:
+//   • multi-user inbound (VMess/VLess/Trojan/SS-multi/Hysteria) → per-
+//     client row + share links
+//   • SS single-user → connection details + share link
+//   • WireGuard → secret/peers + per-peer config download
+//   • Mixed/HTTP/Tunnel → connection details only
+//
+// We display links via QrPanel — each link gets its own QR + copy +
+// (for WireGuard configs) download button.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  // Result of inbounds-page checkFallback() so the link-gen sees the
+  // root inbound's listen/port/security when the dbInbound is a
+  // domain-socket fallback (`@<name>`).
+  dbInbound: { type: Object, default: null },
+  // Index into inbound.clients to focus on for multi-user inbounds.
+  clientIndex: { type: Number, default: 0 },
+  // Sidecar config the legacy panel keyed off `app.*`.
+  remarkModel: { type: String, default: '-ieo' },
+  expireDiff: { type: Number, default: 0 },
+  trafficDiff: { type: Number, default: 0 },
+  ipLimitEnable: { type: Boolean, default: false },
+  tgBotEnable: { type: Boolean, default: false },
+  // Address of the node hosting this inbound; '' for local. Wired
+  // through to share/QR link generation so node-managed inbounds
+  // produce links that connect to the node, not the central panel.
+  nodeAddress: { type: String, default: '' },
+  subSettings: {
+    type: Object,
+    default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
+  },
+  // Email -> ts (last-online unix-ms) map fetched at the page level.
+  lastOnlineMap: { type: Object, default: () => ({}) },
+});
+
+const emit = defineEmits(['update:open']);
+
+// Cloned state on open so cancel doesn't leak edits onto the row's
+// parsed-cache copy. The local ref intentionally shadows the prop —
+// templates read this ref's frozen-on-open value, not props.dbInbound.
+// eslint-disable-next-line vue/no-dupe-keys
+const dbInbound = ref(null);
+const inbound = ref(null);
+const clientSettings = ref(null);
+const clientStats = ref(null);
+
+const links = ref([]); // generic share links (for VMess/VLess/Trojan/SS/Hysteria)
+const wireguardConfigs = ref([]); // multi-line .conf bodies (one per peer)
+const wireguardLinks = ref([]); // wg:// share URIs (one per peer)
+
+const subLink = ref('');
+const subJsonLink = ref('');
+
+// IP-log state (matches the legacy refresh / clear flow).
+const refreshing = ref(false);
+const clientIpsArray = ref([]);
+const clientIpsText = ref('');
+
+// === Status flags shown as tags ====================================
+const isEnable = computed(() => {
+  if (clientSettings.value) return !!clientSettings.value.enable;
+  return dbInbound.value?.enable ?? true;
+});
+
+const isDepleted = computed(() => {
+  const stats = clientStats.value;
+  const settings = clientSettings.value;
+  if (!stats || !settings) return false;
+  const total = stats.total ?? 0;
+  const used = (stats.up ?? 0) + (stats.down ?? 0);
+  if (total > 0 && used >= total) return true;
+  const expiry = settings.expiryTime ?? 0;
+  if (expiry > 0 && Date.now() >= expiry) return true;
+  return false;
+});
+
+function statsColor(stats) {
+  return ColorUtils.usageColor(stats.up + stats.down, props.trafficDiff, stats.total);
+}
+
+function getRemainingStats() {
+  if (!clientStats.value || !clientSettings.value) return '-';
+  const remained = clientStats.value.total - clientStats.value.up - clientStats.value.down;
+  return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
+}
+
+function formatLastOnline(email) {
+  const ts = props.lastOnlineMap[email];
+  if (!ts) return '-';
+  return IntlUtil.formatDate(ts, datepicker.value);
+}
+
+// === IP log ========================================================
+function formatIpInfo(record) {
+  if (record == null) return '';
+  if (typeof record === 'string' || typeof record === 'number') return String(record);
+  const ip = record.ip || record.IP || '';
+  const ts = record.timestamp || record.Timestamp || 0;
+  if (!ip) return String(record);
+  if (!ts) return String(ip);
+  const date = new Date(Number(ts) * 1000);
+  const timeStr = date
+    .toLocaleString('en-GB', {
+      year: 'numeric', month: '2-digit', day: '2-digit',
+      hour: '2-digit', minute: '2-digit', second: '2-digit',
+      hour12: false,
+    })
+    .replace(',', '');
+  return `${ip} (${timeStr})`;
+}
+
+async function loadClientIps() {
+  if (!clientStats.value?.email) return;
+  refreshing.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${clientStats.value.email}`);
+    if (!msg?.success) {
+      clientIpsText.value = msg?.obj || 'No IP record';
+      clientIpsArray.value = [];
+      return;
+    }
+    let ips = msg.obj;
+    if (typeof ips === 'string') {
+      try { ips = JSON.parse(ips); }
+      catch (_e) { clientIpsText.value = String(ips); clientIpsArray.value = [String(ips)]; return; }
+    }
+    if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
+    if (Array.isArray(ips) && ips.length > 0) {
+      const arr = ips.map(formatIpInfo).filter(Boolean);
+      clientIpsArray.value = arr;
+      clientIpsText.value = arr.join(' | ');
+    } else {
+      clientIpsArray.value = [];
+      clientIpsText.value = String(ips || t('tgbot.noIpRecord'));
+    }
+  } finally {
+    refreshing.value = false;
+  }
+}
+
+async function clearClientIps() {
+  if (!clientStats.value?.email) return;
+  const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${clientStats.value.email}`);
+  if (msg?.success) {
+    clientIpsArray.value = [];
+    clientIpsText.value = t('tgbot.noIpRecord');
+  }
+}
+
+async function copyText(value) {
+  const ok = await ClipboardManager.copyText(String(value ?? ''));
+  if (ok) message.success(t('copied'));
+}
+
+function downloadText(content, filename) {
+  FileManager.downloadTextFile(content, filename);
+}
+
+// Active tab in the 3-pane layout. Reset on each open below.
+const activeTab = ref('inbound');
+
+// === Build state on open ===========================================
+function genSubLink(subId) {
+  return (props.subSettings.subURI || '') + subId;
+}
+function genSubJsonLink(subId) {
+  return (props.subSettings.subJsonURI || '') + subId;
+}
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (!props.dbInbound) return;
+
+  activeTab.value = 'inbound';
+  dbInbound.value = props.dbInbound;
+  inbound.value = props.dbInbound.toInbound();
+
+  const idx = props.clientIndex ?? 0;
+  if (inbound.value.clients?.length) {
+    clientSettings.value = inbound.value.clients[idx] || null;
+  } else {
+    clientSettings.value = null;
+  }
+  clientStats.value = clientSettings.value
+    ? (props.dbInbound.clientStats || []).find((s) => s.email === clientSettings.value.email) || null
+    : null;
+
+  // Generate links per protocol — WireGuard has its own .conf body
+  // path; everything else flows through genAllLinks.
+  if (inbound.value.protocol === Protocols.WIREGUARD) {
+    wireguardConfigs.value = inbound.value.genWireguardConfigs(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n');
+    wireguardLinks.value = inbound.value.genWireguardLinks(props.dbInbound.remark, '-ieo', props.nodeAddress).split('\r\n');
+    links.value = [];
+  } else {
+    links.value = inbound.value.genAllLinks(
+      props.dbInbound.remark,
+      props.remarkModel,
+      clientSettings.value,
+      props.nodeAddress,
+    );
+    wireguardConfigs.value = [];
+    wireguardLinks.value = [];
+  }
+
+  // Subscription link is per-client because each client has its own subId.
+  if (clientSettings.value?.subId) {
+    subLink.value = genSubLink(clientSettings.value.subId);
+    subJsonLink.value = props.subSettings.subJsonEnable
+      ? genSubJsonLink(clientSettings.value.subId)
+      : '';
+  } else {
+    subLink.value = '';
+    subJsonLink.value = '';
+  }
+
+  // Auto-load IP log if it'll be visible.
+  clientIpsArray.value = [];
+  clientIpsText.value = '';
+  if (
+    props.ipLimitEnable
+    && clientSettings.value?.limitIp > 0
+    && clientStats.value?.email
+  ) {
+    loadClientIps();
+  }
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+// === Convenience displays ===========================================
+const networkLabel = computed(() => inbound.value?.stream?.network || '');
+const securityLabel = computed(() => inbound.value?.stream?.security || 'none');
+const securityColor = computed(() => (securityLabel.value === 'none' ? 'red' : 'green'));
+const encryptionLabel = computed(() => inbound.value?.settings?.encryption || '');
+const serverNameLabel = computed(() => inbound.value?.serverName || '');
+
+// === Tab visibility =================================================
+const showClientTab = computed(() => !!clientSettings.value);
+const showSubscriptionTab = computed(
+  () => !!(props.subSettings.enable && clientSettings.value?.subId),
+);
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.inbounds.inboundData')" :footer="null" width="640px" @cancel="close">
+    <template v-if="dbInbound && inbound">
+      <a-tabs v-model:active-key="activeTab">
+        <!-- ============================================================
+             TAB 1 — Inbound: protocol, transport, security, per-protocol
+        ============================================================== -->
+        <a-tab-pane key="inbound" :tab="t('pages.xray.rules.inbound')">
+          <dl class="info-list">
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.protocol') }}</dt>
+              <dd><a-tag color="purple">{{ dbInbound.protocol }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.address') }}</dt>
+              <dd><a-tag class="value-tag">{{ dbInbound.address }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.port') }}</dt>
+              <dd><a-tag>{{ dbInbound.port }}</a-tag></dd>
+            </div>
+
+            <template v-if="dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS">
+              <div class="info-row">
+                <dt>{{ t('transmission') }}</dt>
+                <dd><a-tag color="green">{{ networkLabel }}</a-tag></dd>
+              </div>
+              <template v-if="inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP">
+                <div class="info-row">
+                  <dt>{{ t('host') }}</dt>
+                  <dd>
+                    <a-tag v-if="inbound.host" class="value-tag">{{ inbound.host }}</a-tag>
+                    <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+                  </dd>
+                </div>
+                <div class="info-row">
+                  <dt>{{ t('path') }}</dt>
+                  <dd>
+                    <a-tag v-if="inbound.path" class="value-tag">{{ inbound.path }}</a-tag>
+                    <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+                  </dd>
+                </div>
+              </template>
+              <template v-if="inbound.isXHTTP">
+                <div class="info-row">
+                  <dt>Mode</dt>
+                  <dd><a-tag>{{ inbound.stream.xhttp.mode }}</a-tag></dd>
+                </div>
+              </template>
+              <template v-if="inbound.isGrpc">
+                <div class="info-row">
+                  <dt>grpc serviceName</dt>
+                  <dd><a-tag class="value-tag">{{ inbound.serviceName }}</a-tag></dd>
+                </div>
+                <div class="info-row">
+                  <dt>grpc multiMode</dt>
+                  <dd><a-tag>{{ inbound.stream.grpc.multiMode }}</a-tag></dd>
+                </div>
+              </template>
+            </template>
+
+            <template v-if="dbInbound.hasLink()">
+              <div class="info-row">
+                <dt>{{ t('security') }}</dt>
+                <dd><a-tag :color="securityColor">{{ securityLabel }}</a-tag></dd>
+              </div>
+              <div v-if="encryptionLabel" class="info-row">
+                <dt>{{ t('encryption') }}</dt>
+                <dd class="value-block">
+                  <code class="value-code">{{ encryptionLabel }}</code>
+                  <a-tooltip :title="t('copy')">
+                    <a-button size="small" class="value-copy" @click="copyText(encryptionLabel)">
+                      <template #icon>
+                        <CopyOutlined />
+                      </template>
+                    </a-button>
+                  </a-tooltip>
+                </dd>
+              </div>
+              <div v-if="securityLabel !== 'none'" class="info-row">
+                <dt>{{ t('domainName') }}</dt>
+                <dd>
+                  <a-tag v-if="serverNameLabel" color="green" class="value-tag">{{ serverNameLabel }}</a-tag>
+                  <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+                </dd>
+              </div>
+            </template>
+          </dl>
+
+          <!-- Shadowsocks single-user details -->
+          <table v-if="dbInbound.isSS" class="info-table block">
+            <tbody>
+              <tr>
+                <td>{{ t('encryption') }}</td>
+                <td><a-tag color="green">{{ inbound.settings.method }}</a-tag></td>
+              </tr>
+              <tr v-if="inbound.isSS2022">
+                <td>{{ t('password') }}</td>
+                <td><a-tag class="info-large-tag">{{ inbound.settings.password }}</a-tag></td>
+              </tr>
+              <tr>
+                <td>{{ t('pages.inbounds.network') }}</td>
+                <td><a-tag color="green">{{ inbound.settings.network }}</a-tag></td>
+              </tr>
+            </tbody>
+          </table>
+
+          <!-- Tunnel -->
+          <dl v-if="inbound.protocol === Protocols.TUNNEL" class="info-list info-list-block">
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.targetAddress') }}</dt>
+              <dd><a-tag color="green" class="value-tag">{{ inbound.settings.address }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.destinationPort') }}</dt>
+              <dd><a-tag color="green">{{ inbound.settings.port }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>{{ t('pages.inbounds.network') }}</dt>
+              <dd><a-tag color="green">{{ inbound.settings.network }}</a-tag></dd>
+            </div>
+            <div class="info-row">
+              <dt>FollowRedirect</dt>
+              <dd>
+                <a-tag :color="inbound.settings.followRedirect ? 'green' : 'red'">
+                  {{ inbound.settings.followRedirect ? t('enabled') : t('disabled') }}
+                </a-tag>
+              </dd>
+            </div>
+          </dl>
+
+          <!-- Mixed -->
+          <dl v-if="dbInbound.isMixed" class="info-list info-list-block">
+            <div class="info-row">
+              <dt>Auth</dt>
+              <dd>
+                <a-tag :color="inbound.settings.auth === 'password' ? 'green' : 'orange'">
+                  {{ inbound.settings.auth }}
+                </a-tag>
+              </dd>
+            </div>
+            <div class="info-row">
+              <dt>UDP</dt>
+              <dd>
+                <a-tag :color="inbound.settings.udp ? 'green' : 'red'">
+                  {{ inbound.settings.udp ? t('enabled') : t('disabled') }}
+                </a-tag>
+              </dd>
+            </div>
+            <div v-if="inbound.settings.ip" class="info-row">
+              <dt>IP</dt>
+              <dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
+            </div>
+            <template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
+              <div
+                v-for="(account, idx) in inbound.settings.accounts"
+                :key="idx"
+                class="info-row"
+              >
+                <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
+                <dd class="account-row">
+                  <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
+                  <span class="account-sep">:</span>
+                  <a-tag class="value-tag">{{ account.pass }}</a-tag>
+                  <a-tooltip :title="t('copy')">
+                    <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
+                      <template #icon>
+                        <CopyOutlined />
+                      </template>
+                    </a-button>
+                  </a-tooltip>
+                </dd>
+              </div>
+            </template>
+          </dl>
+
+          <!-- HTTP accounts -->
+          <dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
+            <div
+              v-for="(account, idx) in inbound.settings.accounts"
+              :key="idx"
+              class="info-row"
+            >
+              <dt>{{ t('username') }} #{{ idx + 1 }}</dt>
+              <dd class="account-row">
+                <a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
+                <span class="account-sep">:</span>
+                <a-tag class="value-tag">{{ account.pass }}</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </dd>
+            </div>
+          </dl>
+
+          <!-- WireGuard server config + peers -->
+          <table v-if="dbInbound.isWireguard" class="info-table protocol-table wg-table">
+            <tbody>
+              <tr>
+                <td>Secret key</td>
+                <td>{{ inbound.settings.secretKey }}</td>
+              </tr>
+              <tr>
+                <td>Public key</td>
+                <td>{{ inbound.settings.pubKey }}</td>
+              </tr>
+              <tr>
+                <td>MTU</td>
+                <td>{{ inbound.settings.mtu }}</td>
+              </tr>
+              <tr>
+                <td>No-kernel TUN</td>
+                <td>{{ inbound.settings.noKernelTun }}</td>
+              </tr>
+              <template v-for="(peer, idx) in inbound.settings.peers" :key="idx">
+                <tr>
+                  <td colspan="2"><a-divider>Peer {{ idx + 1 }}</a-divider></td>
+                </tr>
+                <tr>
+                  <td>Secret key</td>
+                  <td>{{ peer.privateKey }}</td>
+                </tr>
+                <tr>
+                  <td>Public key</td>
+                  <td>{{ peer.publicKey }}</td>
+                </tr>
+                <tr>
+                  <td>PSK</td>
+                  <td>{{ peer.psk }}</td>
+                </tr>
+                <tr>
+                  <td>Allowed IPs</td>
+                  <td>{{ (peer.allowedIPs || []).join(',') }}</td>
+                </tr>
+                <tr>
+                  <td>Keep alive</td>
+                  <td>{{ peer.keepAlive }}</td>
+                </tr>
+                <tr v-if="wireguardConfigs[idx]">
+                  <td colspan="2">
+                    <div class="link-panel">
+                      <div class="link-panel-header">
+                        <a-tag color="green">Peer {{ idx + 1 }} config</a-tag>
+                        <a-tooltip :title="t('copy')">
+                          <a-button size="small" @click="copyText(wireguardConfigs[idx])">
+                            <template #icon>
+                              <CopyOutlined />
+                            </template>
+                          </a-button>
+                        </a-tooltip>
+                        <a-tooltip :title="t('download')">
+                          <a-button size="small" @click="downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)">
+                            <template #icon>
+                              <DownloadOutlined />
+                            </template>
+                          </a-button>
+                        </a-tooltip>
+                      </div>
+                      <code class="link-panel-text">{{ wireguardConfigs[idx] }}</code>
+                    </div>
+                  </td>
+                </tr>
+                <tr v-if="wireguardLinks[idx]">
+                  <td colspan="2">
+                    <div class="link-panel">
+                      <div class="link-panel-header">
+                        <a-tag color="green">Peer {{ idx + 1 }} link</a-tag>
+                        <a-tooltip :title="t('copy')">
+                          <a-button size="small" @click="copyText(wireguardLinks[idx])">
+                            <template #icon>
+                              <CopyOutlined />
+                            </template>
+                          </a-button>
+                        </a-tooltip>
+                      </div>
+                      <code class="link-panel-text">{{ wireguardLinks[idx] }}</code>
+                    </div>
+                  </td>
+                </tr>
+              </template>
+            </tbody>
+          </table>
+
+          <!-- Single-user SS share link (no QR) -->
+          <template v-if="dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0">
+            <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
+            <div v-for="(link, idx) in links" :key="idx" class="link-panel">
+              <div class="link-panel-header">
+                <a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(link.link)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <code class="link-panel-text">{{ link.link }}</code>
+            </div>
+          </template>
+        </a-tab-pane>
+
+        <!-- ============================================================
+             TAB 2 — Client: per-client info + share links (no QR)
+        ============================================================== -->
+        <a-tab-pane v-if="showClientTab" key="client" :tab="t('pages.inbounds.client')">
+          <table class="info-table block">
+            <tbody>
+              <tr>
+                <td>{{ t('pages.inbounds.email') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.email" color="green">{{ clientSettings.email }}</a-tag>
+                  <a-tag v-else color="red">{{ t('none') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientSettings.id">
+                <td>ID</td>
+                <td><a-tag>{{ clientSettings.id }}</a-tag></td>
+              </tr>
+              <tr v-if="dbInbound.isVMess">
+                <td>{{ t('security') }}</td>
+                <td><a-tag>{{ clientSettings.security }}</a-tag></td>
+              </tr>
+              <tr v-if="inbound.canEnableTlsFlow()">
+                <td>Flow</td>
+                <td>
+                  <a-tag v-if="clientSettings.flow">{{ clientSettings.flow }}</a-tag>
+                  <a-tag v-else color="orange">{{ t('none') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientSettings.password">
+                <td>{{ t('password') }}</td>
+                <td><a-tag class="info-large-tag">{{ clientSettings.password }}</a-tag></td>
+              </tr>
+              <tr>
+                <td>{{ t('status') }}</td>
+                <td>
+                  <a-tag v-if="isDepleted" color="red">{{ t('depleted') }}</a-tag>
+                  <a-tag v-else-if="isEnable" color="green">{{ t('enabled') }}</a-tag>
+                  <a-tag v-else>{{ t('disabled') }}</a-tag>
+                </td>
+              </tr>
+              <tr v-if="clientStats">
+                <td>{{ t('usage') }}</td>
+                <td>
+                  <a-tag color="green">
+                    {{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }}
+                  </a-tag>
+                  <a-tag>
+                    ↑ {{ SizeFormatter.sizeFormat(clientStats.up) }} /
+                    {{ SizeFormatter.sizeFormat(clientStats.down) }} ↓
+                  </a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('pages.inbounds.createdAt') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
+                  <a-tag v-else>-</a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('pages.inbounds.updatedAt') }}</td>
+                <td>
+                  <a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
+                  <a-tag v-else>-</a-tag>
+                </td>
+              </tr>
+              <tr>
+                <td>{{ t('lastOnline') }}</td>
+                <td><a-tag>{{ formatLastOnline(clientSettings.email || '') }}</a-tag></td>
+              </tr>
+              <tr v-if="clientSettings.comment">
+                <td>{{ t('comment') }}</td>
+                <td><a-tag class="info-large-tag">{{ clientSettings.comment }}</a-tag></td>
+              </tr>
+              <tr v-if="ipLimitEnable">
+                <td>{{ t('pages.inbounds.IPLimit') }}</td>
+                <td><a-tag>{{ clientSettings.limitIp }}</a-tag></td>
+              </tr>
+              <tr v-if="ipLimitEnable && clientSettings.limitIp > 0">
+                <td>{{ t('pages.inbounds.IPLimitlog') }}</td>
+                <td>
+                  <div class="ip-log">
+                    <div v-if="clientIpsArray.length > 0">
+                      <a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
+                        }}</a-tag>
+                    </div>
+                    <a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
+                  </div>
+                  <div class="ip-log-actions">
+                    <SyncOutlined :spin="refreshing" @click="loadClientIps" />
+                    <a-tooltip :title="t('pages.inbounds.IPLimitlogclear')">
+                      <DeleteOutlined @click="clearClientIps" />
+                    </a-tooltip>
+                  </div>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <!-- Remaining / total / expiry -->
+          <table class="info-table summary-table">
+            <thead>
+              <tr>
+                <th>{{ t('remained') }}</th>
+                <th>{{ t('pages.inbounds.totalUsage') }}</th>
+                <th>{{ t('pages.inbounds.expireDate') }}</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td>
+                  <a-tag v-if="clientStats && clientSettings.totalGB > 0" :color="statsColor(clientStats)">{{
+                    getRemainingStats() }}</a-tag>
+                </td>
+                <td>
+                  <a-tag v-if="clientSettings.totalGB > 0" :color="clientStats ? statsColor(clientStats) : 'default'">{{
+                    SizeFormatter.sizeFormat(clientSettings.totalGB) }}</a-tag>
+                  <a-tag v-else color="purple">
+                    <InfinityIcon />
+                  </a-tag>
+                </td>
+                <td>
+                  <a-tag v-if="clientSettings.expiryTime > 0"
+                    :color="ColorUtils.usageColor(Date.now(), expireDiff, clientSettings.expiryTime)">{{
+                      IntlUtil.formatDate(clientSettings.expiryTime, datepicker) }}</a-tag>
+                  <a-tag v-else-if="clientSettings.expiryTime < 0" color="green">
+                    {{ clientSettings.expiryTime / -86400000 }} {{ t('day') }}
+                  </a-tag>
+                  <a-tag v-else color="purple">
+                    <InfinityIcon />
+                  </a-tag>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+
+          <!-- Telegram chat id -->
+          <template v-if="tgBotEnable && clientSettings.tgId">
+            <a-divider>Telegram</a-divider>
+            <div class="tg-row">
+              <a-tag color="blue">{{ clientSettings.tgId }}</a-tag>
+              <a-tooltip :title="t('copy')">
+                <a-button size="small" @click="copyText(clientSettings.tgId)">
+                  <template #icon>
+                    <CopyOutlined />
+                  </template>
+                </a-button>
+              </a-tooltip>
+            </div>
+          </template>
+
+          <!-- Per-client share links (no QR) -->
+          <template v-if="dbInbound.hasLink() && links.length > 0">
+            <a-divider>{{ t('pages.inbounds.copyLink') }}</a-divider>
+            <div v-for="(link, idx) in links" :key="idx" class="link-panel">
+              <div class="link-panel-header">
+                <a-tag color="green">{{ link.remark || `Link ${idx + 1}` }}</a-tag>
+                <a-tooltip :title="t('copy')">
+                  <a-button size="small" @click="copyText(link.link)">
+                    <template #icon>
+                      <CopyOutlined />
+                    </template>
+                  </a-button>
+                </a-tooltip>
+              </div>
+              <code class="link-panel-text">{{ link.link }}</code>
+            </div>
+          </template>
+        </a-tab-pane>
+
+        <!-- ============================================================
+             TAB 3 — Subscription: clickable subscription URLs
+        ============================================================== -->
+        <a-tab-pane v-if="showSubscriptionTab" key="subscription" :tab="t('subscription.title')">
+          <div class="link-panel">
+            <div class="link-panel-header">
+              <a-tag color="green">{{ t('subscription.title') }}</a-tag>
+              <a-tooltip :title="t('copy')">
+                <a-button size="small" @click="copyText(subLink)">
+                  <template #icon>
+                    <CopyOutlined />
+                  </template>
+                </a-button>
+              </a-tooltip>
+            </div>
+            <a :href="subLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subLink }}</a>
+          </div>
+
+          <div v-if="subSettings.subJsonEnable && subJsonLink" class="link-panel">
+            <div class="link-panel-header">
+              <a-tag color="green">JSON</a-tag>
+              <a-tooltip :title="t('copy')">
+                <a-button size="small" @click="copyText(subJsonLink)">
+                  <template #icon>
+                    <CopyOutlined />
+                  </template>
+                </a-button>
+              </a-tooltip>
+            </div>
+            <a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
+              }}</a>
+          </div>
+        </a-tab-pane>
+      </a-tabs>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.info-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.info-table.block {
+  margin-bottom: 10px;
+}
+
+.info-table td,
+.info-table th {
+  padding: 4px 8px;
+  vertical-align: top;
+}
+
+.info-table th {
+  text-align: center;
+  font-weight: 500;
+}
+
+.info-large-tag {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+
+/* Stacked label/value list — one row per field. Long values wrap
+ * (or fall through to a code block) so they never blow out the modal. */
+.info-list {
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.info-row {
+  display: grid;
+  grid-template-columns: 140px minmax(0, 1fr);
+  align-items: center;
+  gap: 12px;
+  padding: 6px 0;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.12);
+}
+
+.info-row:last-child {
+  border-bottom: none;
+}
+
+/* When info-list is rendered as a second block (e.g. protocol details
+ * after the top transport/security block), give it a small top spacing
+ * so the two groups read as separate. */
+.info-list-block {
+  margin-top: 10px;
+}
+
+.account-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.account-sep {
+  opacity: 0.55;
+  font-weight: 600;
+}
+
+.info-row dt {
+  margin: 0;
+  font-size: 13px;
+  opacity: 0.75;
+}
+
+.info-row dd {
+  margin: 0;
+  min-width: 0;
+}
+
+.value-tag {
+  max-width: 100%;
+  white-space: normal;
+  word-break: break-all;
+  display: inline-block;
+}
+
+.value-block {
+  display: flex;
+  align-items: flex-start;
+  gap: 6px;
+  min-width: 0;
+}
+
+.value-code {
+  flex: 1;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  word-break: break-all;
+  white-space: pre-wrap;
+  padding: 4px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  user-select: all;
+  min-width: 0;
+}
+
+:global(body.dark) .value-code {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.value-copy {
+  flex-shrink: 0;
+}
+
+.security-line {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin: 8px 0;
+}
+
+.security-line span {
+  font-size: 13px;
+  opacity: 0.75;
+}
+
+.summary-table {
+  width: 100%;
+  text-align: center;
+  margin: 10px 0;
+}
+
+.tg-row {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.ip-log {
+  max-height: 150px;
+  overflow-y: auto;
+  text-align: left;
+}
+
+.ip-log-row {
+  display: block;
+  margin: 2px 0;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+}
+
+.ip-log-actions {
+  display: flex;
+  gap: 12px;
+  margin-top: 5px;
+  font-size: 16px;
+  cursor: pointer;
+}
+
+.protocol-table {
+  margin-top: 10px;
+}
+
+.wg-table td {
+  word-break: break-all;
+}
+
+/* Reusable copy/link panel that replaces QrPanel for the no-QR design. */
+.link-panel {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 8px;
+  padding: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.link-panel-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.link-panel-text {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  white-space: pre-wrap;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  user-select: all;
+}
+
+:global(body.dark) .link-panel-text {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+.link-panel-anchor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 11px;
+  word-break: break-all;
+  padding: 6px 8px;
+  background: rgba(0, 0, 0, 0.04);
+  border-radius: 4px;
+  color: var(--ant-color-primary, #1677ff);
+  text-decoration: underline;
+  text-decoration-color: rgba(22, 119, 255, 0.4);
+  transition: background 120ms ease, text-decoration-color 120ms ease;
+}
+
+.link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.08);
+  text-decoration-color: var(--ant-color-primary, #1677ff);
+}
+
+:global(body.dark) .link-panel-anchor {
+  background: rgba(255, 255, 255, 0.05);
+}
+
+:global(body.dark) .link-panel-anchor:hover {
+  background: rgba(22, 119, 255, 0.16);
+}
+</style>

+ 621 - 0
frontend/src/pages/inbounds/InboundList.vue

@@ -0,0 +1,621 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  PlusOutlined,
+  MenuOutlined,
+  SearchOutlined,
+  FilterOutlined,
+  MoreOutlined,
+  EditOutlined,
+  QrcodeOutlined,
+  UserAddOutlined,
+  UsergroupAddOutlined,
+  CopyOutlined,
+  FileDoneOutlined,
+  ExportOutlined,
+  ImportOutlined,
+  ReloadOutlined,
+  RestOutlined,
+  RetweetOutlined,
+  BlockOutlined,
+  DeleteOutlined,
+  InfoCircleOutlined,
+} from '@ant-design/icons-vue';
+
+import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
+import { DBInbound } from '@/models/dbinbound.js';
+import { Inbound } from '@/models/inbound.js';
+import InfinityIcon from '@/components/InfinityIcon.vue';
+import ClientRowTable from './ClientRowTable.vue';
+import { useDatepicker } from '@/composables/useDatepicker.js';
+
+const { datepicker } = useDatepicker();
+
+const { t } = useI18n();
+
+const props = defineProps({
+  dbInbounds: { type: Array, required: true },
+  clientCount: { type: Object, required: true },
+  onlineClients: { type: Array, required: true },
+  lastOnlineMap: { type: Object, default: () => ({}) },
+  expireDiff: { type: Number, default: 0 },
+  trafficDiff: { type: Number, default: 0 },
+  pageSize: { type: Number, default: 0 },
+  isMobile: { type: Boolean, default: false },
+  isDarkTheme: { type: Boolean, default: false },
+  subEnable: { type: Boolean, default: false },
+  // Map node id -> node row, supplied by the parent page so each
+  // inbound row can render its node name without an extra fetch.
+  nodesById: { type: Map, default: () => new Map() },
+});
+
+const emit = defineEmits([
+  'refresh',
+  'add-inbound',
+  'general-action',
+  'row-action',
+  // Per-client events surfaced from the expand-row table.
+  'edit-client',
+  'qrcode-client',
+  'info-client',
+  'reset-traffic-client',
+  'delete-client',
+  'toggle-enable-client',
+]);
+
+// ============ Toolbar / search & filter =============================
+const enableFilter = ref(false);
+const searchKey = ref('');
+const filterBy = ref('');
+
+// Toggle the filter mode — flip cleans the other input.
+function onToggleFilter() {
+  if (enableFilter.value) searchKey.value = '';
+  else filterBy.value = '';
+}
+
+// ============ Search / filter projection =============================
+// Mirrors the legacy logic: when searching, keep inbounds that match
+// anywhere (deep search); when filtering, keep inbounds that have at
+// least one client in the requested bucket and reduce their settings
+// to that bucket.
+function projectInbound(dbInbound, predicate) {
+  const next = new DBInbound(dbInbound);
+  let settings;
+  try {
+    settings = JSON.parse(dbInbound.settings || '{}');
+  } catch (_e) {
+    settings = {};
+  }
+  if (!Array.isArray(settings.clients)) return next;
+  const filtered = settings.clients.filter(predicate);
+  next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
+  next.invalidateCache();
+  return next;
+}
+
+const visibleInbounds = computed(() => {
+  if (enableFilter.value) {
+    if (ObjectUtil.isEmpty(filterBy.value)) return [...props.dbInbounds];
+    const out = [];
+    for (const dbInbound of props.dbInbounds) {
+      const c = props.clientCount[dbInbound.id];
+      if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
+      const list = c[filterBy.value];
+      out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
+    }
+    return out;
+  }
+  if (ObjectUtil.isEmpty(searchKey.value)) return [...props.dbInbounds];
+  const out = [];
+  for (const dbInbound of props.dbInbounds) {
+    if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
+    out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
+  }
+  return out;
+});
+
+// ============ Columns =================================================
+// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
+// `responsive` array still works on column defs. Computed so column
+// labels react to live locale switches.
+const desktopColumns = computed(() => {
+  const cols = [
+    { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
+    { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
+    { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
+    { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
+  ];
+  if (props.nodesById.size > 0) {
+    cols.push({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 });
+  }
+  cols.push(
+    { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
+    { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 },
+    { title: t('clients'), key: 'clients', align: 'left', width: 50 },
+    { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
+    { title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 },
+    { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
+  );
+  return cols;
+});
+const mobileColumns = computed(() => [
+  { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] },
+  { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 25 },
+  { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'left', width: 70 },
+  { title: t('info'), key: 'info', align: 'center', width: 10 },
+]);
+const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
+
+// ============ Pagination ============================================
+function paginationFor(rows) {
+  const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
+  return {
+    pageSize: size,
+    showSizeChanger: false,
+    hideOnSinglePage: true,
+  };
+}
+
+// ============ Per-row enable switch =================================
+async function onSwitchEnable(dbInbound, next) {
+  const previous = dbInbound.enable;
+  dbInbound.enable = next; // optimistic
+  try {
+    const formData = new FormData();
+    formData.append('enable', String(next));
+    const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
+    if (!msg?.success) dbInbound.enable = previous;
+  } catch (_e) {
+    dbInbound.enable = previous;
+  }
+}
+
+// ============ Helpers shared with the templates =====================
+// Whether to show the "Switch xray" / qrcode menu entry — same predicate
+// as legacy: SS single-user inbounds and WireGuard inbounds expose
+// inbound-wide QR codes.
+function showQrCodeMenu(dbInbound) {
+  if (dbInbound.isWireguard) return true;
+  if (dbInbound.isSS) {
+    try {
+      return !dbInbound.toInbound().isSSMultiUser;
+    } catch (_e) {
+      return false;
+    }
+  }
+  return false;
+}
+</script>
+
+<template>
+  <a-card hoverable>
+    <template #title>
+      <a-space direction="horizontal">
+        <a-button type="primary" @click="emit('add-inbound')">
+          <template #icon>
+            <PlusOutlined />
+          </template>
+          <template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
+        </a-button>
+        <a-dropdown :trigger="['click']">
+          <a-button type="primary">
+            <template #icon>
+              <MenuOutlined />
+            </template>
+            <template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
+          </a-button>
+          <template #overlay>
+            <a-menu @click="(a) => emit('general-action', a.key)">
+              <a-menu-item key="import">
+                <ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
+              </a-menu-item>
+              <a-menu-item key="export">
+                <ExportOutlined /> {{ t('pages.inbounds.export') }}
+              </a-menu-item>
+              <a-menu-item v-if="subEnable" key="subs">
+                <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+              </a-menu-item>
+              <a-menu-item key="resetInbounds">
+                <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
+              </a-menu-item>
+              <a-menu-item key="resetClients">
+                <FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
+              </a-menu-item>
+              <a-menu-item key="delDepletedClients" class="danger-item">
+                <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+              </a-menu-item>
+            </a-menu>
+          </template>
+        </a-dropdown>
+      </a-space>
+    </template>
+
+    <a-space direction="vertical" :style="{ width: '100%' }">
+      <!-- Search / filter toolbar -->
+      <div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
+        <a-switch v-model:checked="enableFilter" @change="onToggleFilter">
+          <template #checkedChildren>
+            <SearchOutlined />
+          </template>
+          <template #unCheckedChildren>
+            <FilterOutlined />
+          </template>
+        </a-switch>
+        <a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
+          :size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
+        <a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
+          :size="isMobile ? 'small' : 'middle'">
+          <a-radio-button value="">{{ t('none') }}</a-radio-button>
+          <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
+          <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
+          <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
+          <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
+          <a-radio-button value="online">{{ t('online') }}</a-radio-button>
+        </a-radio-group>
+      </div>
+
+      <a-table :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
+        :pagination="paginationFor(visibleInbounds)" :scroll="isMobile ? {} : { x: 1000 }"
+        :style="{ marginTop: '10px' }" size="small"
+        :row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
+        <!-- Per-inbound client list, expanded by clicking the row's
+             default expand chevron. Hidden via row-class-name for
+             non-multi-user inbounds (matches legacy behavior). -->
+        <template #expandedRowRender="{ record }">
+          <ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
+            :traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
+            :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" @edit-client="(p) => emit('edit-client', p)"
+            @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)"
+            @toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
+        </template>
+
+        <template #bodyCell="{ column, record }">
+          <!-- ============== Action dropdown ============== -->
+          <template v-if="column.key === 'action'">
+            <a-dropdown :trigger="['click']">
+              <MoreOutlined class="row-action-trigger" @click.prevent />
+              <template #overlay>
+                <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
+                  <a-menu-item key="edit">
+                    <EditOutlined /> {{ t('edit') }}
+                  </a-menu-item>
+                  <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
+                    <QrcodeOutlined /> {{ t('qrCode') }}
+                  </a-menu-item>
+                  <template v-if="record.isMultiUser()">
+                    <a-menu-item key="addClient">
+                      <UserAddOutlined /> {{ t('pages.client.add') }}
+                    </a-menu-item>
+                    <a-menu-item key="addBulkClient">
+                      <UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
+                    </a-menu-item>
+                    <a-menu-item key="copyClients">
+                      <CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
+                    </a-menu-item>
+                    <a-menu-item key="resetClients">
+                      <FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
+                    </a-menu-item>
+                    <a-menu-item key="export">
+                      <ExportOutlined /> {{ t('pages.inbounds.export') }}
+                    </a-menu-item>
+                    <a-menu-item v-if="subEnable" key="subs">
+                      <ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
+                    </a-menu-item>
+                    <a-menu-item key="delDepletedClients" class="danger-item">
+                      <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
+                    </a-menu-item>
+                  </template>
+                  <template v-else>
+                    <a-menu-item key="showInfo">
+                      <InfoCircleOutlined /> {{ t('info') }}
+                    </a-menu-item>
+                  </template>
+                  <a-menu-item key="clipboard">
+                    <CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
+                  </a-menu-item>
+                  <a-menu-item key="resetTraffic">
+                    <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                  </a-menu-item>
+                  <a-menu-item key="clone">
+                    <BlockOutlined /> {{ t('pages.inbounds.clone') }}
+                  </a-menu-item>
+                  <a-menu-item key="delete" class="danger-item">
+                    <DeleteOutlined /> {{ t('delete') }}
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </template>
+
+          <!-- ============== Enable switch (desktop) ============== -->
+          <template v-else-if="column.key === 'enable'">
+            <a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
+          </template>
+
+          <!-- ============== Node deployment tag ============== -->
+          <template v-else-if="column.key === 'node'">
+            <template v-if="record.nodeId == null">
+              <a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
+            </template>
+            <template v-else-if="nodesById.get(record.nodeId)">
+              <a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
+                {{ nodesById.get(record.nodeId).name }}
+              </a-tag>
+            </template>
+            <template v-else>
+              <!-- Node row was deleted but inbound still references it. -->
+              <a-tag color="orange">node #{{ record.nodeId }}</a-tag>
+            </template>
+          </template>
+
+          <!-- ============== Protocol tags ============== -->
+          <template v-else-if="column.key === 'protocol'">
+            <div class="protocol-tags">
+              <a-tag color="purple">{{ record.protocol }}</a-tag>
+              <template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS">
+                <a-tag color="green">{{ record.toInbound().stream.network }}</a-tag>
+                <a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
+                <a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
+              </template>
+            </div>
+          </template>
+
+          <!-- ============== Clients tag + popovers ============== -->
+          <template v-else-if="column.key === 'clients'">
+            <template v-if="clientCount[record.id]">
+              <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>
+                </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>
+                </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>
+                </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>
+                </template>
+                <a-tag color="blue" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
+              </a-popover>
+            </template>
+          </template>
+
+          <!-- ============== Traffic ============== -->
+          <template v-else-if="column.key === 'traffic'">
+            <a-popover>
+              <template #content>
+                <table cellpadding="2">
+                  <tbody>
+                    <tr>
+                      <td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
+                      <td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
+                    </tr>
+                    <tr v-if="record.total > 0 && record.up + record.down < record.total">
+                      <td>{{ t('remained') }}</td>
+                      <td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
+                    </tr>
+                  </tbody>
+                </table>
+              </template>
+              <a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
+                {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
+                <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
+                <InfinityIcon v-else />
+              </a-tag>
+            </a-popover>
+          </template>
+
+          <!-- ============== All-time inbound traffic ============== -->
+          <template v-else-if="column.key === 'allTimeInbound'">
+            <a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
+          </template>
+
+          <!-- ============== Expiry ============== -->
+          <template v-else-if="column.key === 'expiryTime'">
+            <a-popover v-if="record.expiryTime > 0">
+              <template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
+              <a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
+                {{ IntlUtil.formatRelativeTime(record.expiryTime) }}
+              </a-tag>
+            </a-popover>
+            <a-tag v-else color="purple">
+              <InfinityIcon />
+            </a-tag>
+          </template>
+
+          <!-- ============== Mobile info popover ============== -->
+          <template v-else-if="column.key === 'info'">
+            <a-popover placement="bottomRight" trigger="click">
+              <template #content>
+                <table cellpadding="2">
+                  <tbody>
+                    <tr>
+                      <td>{{ t('pages.inbounds.protocol') }}</td>
+                      <td><a-tag color="purple">{{ record.protocol }}</a-tag></td>
+                    </tr>
+                    <tr>
+                      <td>{{ t('pages.inbounds.port') }}</td>
+                      <td><a-tag>{{ record.port }}</a-tag></td>
+                    </tr>
+                    <tr v-if="clientCount[record.id]">
+                      <td>{{ t('clients') }}</td>
+                      <td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td>
+                    </tr>
+                    <tr>
+                      <td>{{ t('pages.inbounds.traffic') }}</td>
+                      <td>
+                        <a-tag>
+                          {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
+                          <template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
+                          <InfinityIcon v-else />
+                        </a-tag>
+                      </td>
+                    </tr>
+                    <tr>
+                      <td>{{ t('pages.inbounds.expireDate') }}</td>
+                      <td>
+                        <a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
+                        <a-tag v-else color="purple">
+                          <InfinityIcon />
+                        </a-tag>
+                      </td>
+                    </tr>
+                  </tbody>
+                </table>
+              </template>
+              <InfoCircleOutlined class="row-info-trigger" />
+            </a-popover>
+          </template>
+        </template>
+      </a-table>
+    </a-space>
+  </a-card>
+</template>
+
+<style scoped>
+.filter-bar {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.filter-bar.mobile {
+  display: block;
+}
+
+.filter-bar.mobile>* {
+  margin-bottom: 4px;
+}
+
+.protocol-tags {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+
+.row-action-trigger,
+.row-info-trigger {
+  font-size: 20px;
+  cursor: pointer;
+}
+
+.danger-item {
+  color: #ff4d4f;
+}
+
+/* Hide the expand chevron on rows whose inbound has no client list
+ * (HTTP/Mixed/Tunnel/WireGuard single-config). */
+:deep(.hide-expand-icon .ant-table-row-expand-icon) {
+  visibility: hidden;
+}
+
+/* Push the expand chevron away from the table's left edge so it has
+ * a little breathing room instead of being flush against the corner. */
+:deep(.ant-table-tbody .ant-table-cell-with-append) {
+  padding-left: 12px;
+}
+
+:deep(.ant-table-row-expand-icon) {
+  margin-inline-end: 10px;
+  margin-inline-start: 4px;
+}
+
+/* Round the table's outer corners — AD-Vue gives .ant-table the radius
+ * token, but the inner header strip and footer touch the edges, so clip
+ * them here. */
+:deep(.ant-table) {
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+:deep(.ant-table-container) {
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+:deep(.ant-table-thead > tr:first-child > *:first-child) {
+  border-start-start-radius: 8px;
+}
+
+:deep(.ant-table-thead > tr:first-child > *:last-child) {
+  border-start-end-radius: 8px;
+}
+
+:deep(.ant-table-tbody > tr:last-child > *:first-child) {
+  border-end-start-radius: 8px;
+}
+
+:deep(.ant-table-tbody > tr:last-child > *:last-child) {
+  border-end-end-radius: 8px;
+}
+
+/* ===== Mobile-tightening ============================================
+ * Below 768px the inbound list is on a tiny viewport — squeeze the
+ * card chrome and table cell padding so the actual rows have room. */
+@media (max-width: 768px) {
+  /* Card header/body breathe less on mobile */
+  :deep(.ant-card-head) {
+    padding: 0 12px;
+    min-height: 44px;
+  }
+
+  :deep(.ant-card-head-title),
+  :deep(.ant-card-extra) {
+    padding: 8px 0;
+  }
+
+  :deep(.ant-card-body) {
+    padding: 8px;
+  }
+
+  /* Filter bar wraps cleanly without forcing block layout (which made
+   * the input + radio group stack on separate full-width lines). */
+  .filter-bar.mobile {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+  }
+
+  .filter-bar.mobile > * {
+    margin-bottom: 0;
+  }
+
+  /* Tighten table cell padding so the 3 visible columns get room. */
+  :deep(.ant-table-thead > tr > th),
+  :deep(.ant-table-tbody > tr > td) {
+    padding: 8px 6px;
+    font-size: 12px;
+  }
+
+  /* Slightly bigger expand chevron (touch target). */
+  :deep(.ant-table-row-expand-icon) {
+    width: 20px;
+    height: 20px;
+    line-height: 18px;
+  }
+
+  /* The action / info icons are the row's primary touch targets. */
+  .row-action-trigger,
+  .row-info-trigger {
+    font-size: 22px;
+    padding: 4px;
+  }
+}
+</style>

+ 692 - 0
frontend/src/pages/inbounds/InboundsPage.vue

@@ -0,0 +1,692 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  SwapOutlined,
+  PieChartOutlined,
+  HistoryOutlined,
+  BarsOutlined,
+  TeamOutlined,
+} from '@ant-design/icons-vue';
+
+import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
+import { Inbound } from '@/models/inbound.js';
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import CustomStatistic from '@/components/CustomStatistic.vue';
+import { useNodeList } from '@/composables/useNodeList.js';
+import InboundList from './InboundList.vue';
+import InboundFormModal from './InboundFormModal.vue';
+import ClientFormModal from './ClientFormModal.vue';
+import ClientBulkModal from './ClientBulkModal.vue';
+import InboundInfoModal from './InboundInfoModal.vue';
+import QrCodeModal from './QrCodeModal.vue';
+import TextModal from '@/components/TextModal.vue';
+import PromptModal from '@/components/PromptModal.vue';
+import { useInbounds } from './useInbounds.js';
+import { useWebSocket } from '@/composables/useWebSocket.js';
+
+const { t } = useI18n();
+
+const {
+  fetched,
+  dbInbounds,
+  clientCount,
+  onlineClients,
+  totals,
+  expireDiff,
+  trafficDiff,
+  pageSize,
+  subSettings,
+  tgBotEnable,
+  ipLimitEnable,
+  remarkModel,
+  lastOnlineMap,
+  refresh,
+  fetchDefaultSettings,
+  applyTrafficEvent,
+  applyClientStatsEvent,
+  applyInvalidate,
+} = useInbounds();
+
+// Live updates over WebSocket — replaces the old 5s polling loop.
+// The backend pushes traffic + per-client deltas every ~10s; we merge
+// them into the local refs in-place so counters and online badges
+// update without re-fetching the whole list.
+useWebSocket({
+  traffic: applyTrafficEvent,
+  client_stats: applyClientStatsEvent,
+  invalidate: applyInvalidate,
+});
+const { isMobile } = useMediaQuery();
+// Node list lives on the central panel; the Inbounds page consumes
+// 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 requestUri = window.location.pathname;
+
+onMounted(async () => {
+  await fetchDefaultSettings();
+  await refresh();
+});
+
+// === Add/Edit modal ===================================================
+const formOpen = ref(false);
+const formMode = ref('add');
+const formDbInbound = ref(null);
+
+// === Client modal (single + bulk) =====================================
+const clientOpen = ref(false);
+const clientMode = ref('add');
+const clientDbInbound = ref(null);
+const clientIndex = ref(null);
+
+const bulkOpen = ref(false);
+const bulkDbInbound = ref(null);
+
+// === Info / QR-code modals ===========================================
+const infoOpen = ref(false);
+const infoDbInbound = ref(null);
+const infoClientIndex = ref(0);
+
+const qrOpen = ref(false);
+const qrDbInbound = ref(null);
+const qrClient = ref(null);
+
+// hostOverrideFor returns the node's address for a node-managed inbound,
+// or '' when the inbound runs locally. Wired into the QR / Info modals
+// and into export-all-links functions so generated share links point at
+// the node, not the central panel.
+function hostOverrideFor(dbInbound) {
+  if (!dbInbound || dbInbound.nodeId == null) return '';
+  return nodesById.value.get(dbInbound.nodeId)?.address || '';
+}
+
+const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
+const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
+
+// === Shared text + prompt modal state =================================
+const textOpen = ref(false);
+const textTitle = ref('');
+const textContent = ref('');
+const textFileName = ref('');
+
+const promptOpen = ref(false);
+const promptTitle = ref('');
+const promptOkText = ref('OK');
+const promptType = ref('textarea');
+const promptInitial = ref('');
+const promptLoading = ref(false);
+let promptHandler = null;
+
+function openText({ title, content, fileName = '' }) {
+  textTitle.value = title;
+  textContent.value = content;
+  textFileName.value = fileName;
+  textOpen.value = true;
+}
+
+function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
+  promptTitle.value = title;
+  promptOkText.value = okText || 'OK';
+  promptType.value = type;
+  promptInitial.value = value;
+  promptHandler = confirm;
+  promptOpen.value = true;
+}
+
+async function onPromptConfirm(value) {
+  if (!promptHandler) { promptOpen.value = false; return; }
+  promptLoading.value = true;
+  try {
+    const ok = await promptHandler(value);
+    if (ok !== false) promptOpen.value = false;
+  } finally {
+    promptLoading.value = false;
+  }
+}
+
+// === Export helpers — mirror legacy txtModal call sites ==============
+function exportInboundLinks(dbInbound) {
+  const projected = checkFallback(dbInbound);
+  openText({
+    title: 'Export inbound links',
+    content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
+    fileName: projected.remark || 'inbound',
+  });
+}
+
+function exportInboundClipboard(dbInbound) {
+  openText({
+    title: 'Inbound JSON',
+    content: JSON.stringify(dbInbound, null, 2),
+  });
+}
+
+function exportInboundSubs(dbInbound) {
+  const inbound = dbInbound.toInbound();
+  const clients = inbound?.clients || [];
+  const subLinks = [];
+  for (const c of clients) {
+    if (c.subId && subSettings.value.subURI) {
+      subLinks.push(subSettings.value.subURI + c.subId);
+    }
+  }
+  openText({
+    title: 'Export subscription links',
+    content: [...new Set(subLinks)].join('\n'),
+    fileName: `${dbInbound.remark || 'inbound'}-Subs`,
+  });
+}
+
+function exportAllLinks() {
+  const out = [];
+  for (const ib of dbInbounds.value) {
+    out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
+  }
+  openText({
+    title: 'Export all inbound links',
+    content: out.join('\r\n'),
+    fileName: 'All-Inbounds',
+  });
+}
+
+function exportAllSubs() {
+  const out = [];
+  for (const ib of dbInbounds.value) {
+    const inbound = ib.toInbound();
+    const clients = inbound?.clients || [];
+    for (const c of clients) {
+      if (c.subId && subSettings.value.subURI) {
+        out.push(subSettings.value.subURI + c.subId);
+      }
+    }
+  }
+  openText({
+    title: 'Export all subscription links',
+    content: [...new Set(out)].join('\r\n'),
+    fileName: 'All-Inbounds-Subs',
+  });
+}
+
+function importInbound() {
+  openPrompt({
+    title: 'Import inbound',
+    okText: 'Import',
+    type: 'textarea',
+    value: '',
+    confirm: async (value) => {
+      const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
+      if (msg?.success) {
+        await refresh();
+        return true;
+      }
+      return false;
+    },
+  });
+}
+
+// `checkFallback` mirrors the legacy helper: when an inbound listens
+// on a unix-socket fallback (`@<name>`), point the link generator at
+// the root inbound that owns the listen address so QRs/links carry
+// the externally-reachable host:port and the right TLS state.
+function checkFallback(dbInbound) {
+  // We don't keep parsed Inbounds in state right now (the page works
+  // off DBInbounds); compute on the fly.
+  if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
+  for (const candidate of dbInbounds.value) {
+    if (candidate.id === dbInbound.id) continue;
+    const parsed = candidate.toInbound();
+    if (!parsed.isTcp) continue;
+    if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
+    const fallbacks = parsed.settings.fallbacks || [];
+    if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
+    // Build a one-off DBInbound copy with the parent's listen/port +
+    // copied stream so the link gen sees the public endpoint.
+    const projected = JSON.parse(JSON.stringify(dbInbound));
+    projected.listen = candidate.listen;
+    projected.port = candidate.port;
+    const inheritedStream = parsed.stream;
+    const ownInbound = dbInbound.toInbound();
+    ownInbound.stream.security = inheritedStream.security;
+    ownInbound.stream.tls = inheritedStream.tls;
+    ownInbound.stream.externalProxy = inheritedStream.externalProxy;
+    projected.streamSettings = ownInbound.stream.toString();
+    // Re-wrap so callers get the same DBInbound shape they had.
+    return new dbInbound.constructor(projected);
+  }
+  return dbInbound;
+}
+
+function findClientIndex(dbInbound, client) {
+  if (!client) return 0;
+  const inbound = dbInbound.toInbound();
+  const clients = inbound?.clients || [];
+  const idx = clients.findIndex((c) => {
+    if (!c) return false;
+    switch (dbInbound.protocol) {
+      case 'trojan':
+      case 'shadowsocks':
+        return c.password === client.password && c.email === client.email;
+      default:
+        return c.id === client.id && c.email === client.email;
+    }
+  });
+  return idx >= 0 ? idx : 0;
+}
+
+function getClientId(protocol, client) {
+  switch (protocol) {
+    case 'trojan': return client.password;
+    case 'shadowsocks': return client.email;
+    case 'hysteria': return client.auth;
+    default: return client.id;
+  }
+}
+
+// === Per-client handlers (called from the expand-row table) =========
+function onEditClient({ dbInbound, client }) {
+  clientMode.value = 'edit';
+  clientDbInbound.value = dbInbound;
+  clientIndex.value = findClientIndex(dbInbound, client);
+  clientOpen.value = true;
+}
+
+function onQrcodeClient({ dbInbound, client }) {
+  qrDbInbound.value = checkFallback(dbInbound);
+  qrClient.value = client || null;
+  qrOpen.value = true;
+}
+
+function onInfoClient({ dbInbound, client }) {
+  infoDbInbound.value = checkFallback(dbInbound);
+  infoClientIndex.value = findClientIndex(dbInbound, client);
+  infoOpen.value = true;
+}
+
+async function onResetTrafficClient({ dbInbound, client }) {
+  const msg = await HttpUtil.post(
+    `/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
+  );
+  if (msg?.success) await refresh();
+}
+
+async function onDeleteClient({ dbInbound, client }) {
+  const clientId = getClientId(dbInbound.protocol, client);
+  const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
+  if (msg?.success) 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
+  // keeps the wire shape identical to the modal save path.
+  const inbound = dbInbound.toInbound();
+  const clients = inbound?.clients || [];
+  const idx = findClientIndex(dbInbound, client);
+  if (idx < 0 || !clients[idx]) return;
+  clients[idx].enable = next;
+  const clientId = getClientId(dbInbound.protocol, clients[idx]);
+  const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
+    id: dbInbound.id,
+    settings: `{"clients": [${clients[idx].toString()}]}`,
+  });
+  if (msg?.success) await refresh();
+}
+
+function onAddInbound() {
+  formMode.value = 'add';
+  formDbInbound.value = null;
+  formOpen.value = true;
+}
+
+function openEdit(dbInbound) {
+  formMode.value = 'edit';
+  formDbInbound.value = dbInbound;
+  formOpen.value = true;
+}
+
+function openAddClient(dbInbound) {
+  clientMode.value = 'add';
+  clientDbInbound.value = dbInbound;
+  clientIndex.value = null;
+  clientOpen.value = true;
+}
+
+function openAddBulkClient(dbInbound) {
+  bulkDbInbound.value = dbInbound;
+  bulkOpen.value = true;
+}
+
+// Per-row destructive actions go through Modal.confirm (matches legacy).
+function confirmDelete(dbInbound) {
+  Modal.confirm({
+    title: `Delete inbound "${dbInbound.remark}"?`,
+    content: 'This removes the inbound and all its clients. This cannot be undone.',
+    okText: 'Delete',
+    okType: 'danger',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
+      if (msg?.success) await refresh();
+    },
+  });
+}
+
+function confirmResetTraffic(dbInbound) {
+  Modal.confirm({
+    title: `Reset traffic for "${dbInbound.remark}"?`,
+    content: 'Resets up/down counters to 0 for this inbound.',
+    okText: 'Reset',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllTraffics`);
+      if (msg?.success) await refresh();
+    },
+  });
+}
+
+function confirmDelDepleted(dbInboundId) {
+  Modal.confirm({
+    title: 'Delete depleted clients?',
+    content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
+    okText: 'Delete',
+    okType: 'danger',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
+      if (msg?.success) await refresh();
+    },
+  });
+}
+
+// Clone — adds a new inbound with the same protocol+stream+sniffing
+// but a fresh remark/port and an empty client list.
+function confirmClone(dbInbound) {
+  Modal.confirm({
+    title: `Clone inbound "${dbInbound.remark}"?`,
+    content: 'Creates a copy with a new port and an empty client list.',
+    okText: 'Clone',
+    cancelText: 'Cancel',
+    onOk: async () => {
+      const baseInbound = dbInbound.toInbound();
+      const data = {
+        up: 0,
+        down: 0,
+        total: 0,
+        remark: `${dbInbound.remark} (clone)`,
+        enable: false,
+        expiryTime: 0,
+        listen: '',
+        port: RandomUtil.randomInteger(10000, 60000),
+        protocol: baseInbound.protocol,
+        settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
+        streamSettings: baseInbound.stream.toString(),
+        sniffing: baseInbound.sniffing.toString(),
+      };
+      const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
+      if (msg?.success) await refresh();
+    },
+  });
+}
+
+function onGeneralAction(key) {
+  switch (key) {
+    case 'import':
+      importInbound();
+      break;
+    case 'export':
+      exportAllLinks();
+      break;
+    case 'subs':
+      exportAllSubs();
+      break;
+    case 'resetInbounds':
+      Modal.confirm({
+        title: 'Reset all inbound traffic?',
+        okText: 'Reset',
+        cancelText: 'Cancel',
+        onOk: async () => {
+          const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
+          if (msg?.success) await refresh();
+        },
+      });
+      break;
+    case 'resetClients':
+      Modal.confirm({
+        title: 'Reset all client traffic across all inbounds?',
+        okText: 'Reset',
+        cancelText: 'Cancel',
+        onOk: async () => {
+          const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
+          if (msg?.success) await refresh();
+        },
+      });
+      break;
+    case 'delDepletedClients':
+      confirmDelDepleted(-1);
+      break;
+    default:
+      message.info(`General action "${key}" — coming in a later 5f subphase`);
+  }
+}
+
+function onRowAction({ key, dbInbound }) {
+  switch (key) {
+    case 'edit':
+      openEdit(dbInbound);
+      break;
+    case 'addClient':
+      openAddClient(dbInbound);
+      break;
+    case 'addBulkClient':
+      openAddBulkClient(dbInbound);
+      break;
+    case 'showInfo':
+      infoDbInbound.value = checkFallback(dbInbound);
+      infoClientIndex.value = findClientIndex(dbInbound, null);
+      infoOpen.value = true;
+      break;
+    case 'qrcode':
+      qrDbInbound.value = checkFallback(dbInbound);
+      qrClient.value = null;
+      qrOpen.value = true;
+      break;
+    case 'export':
+      exportInboundLinks(dbInbound);
+      break;
+    case 'subs':
+      exportInboundSubs(dbInbound);
+      break;
+    case 'clipboard':
+      exportInboundClipboard(dbInbound);
+      break;
+    case 'copyClients':
+      // Copy-clients-from-inbound is a tiny dedicated modal in legacy
+      // (lets you tick clients to copy across inbounds). Defer to a
+      // future commit — surface a friendly message for now.
+      message.info('Copy clients across inbounds — coming soon');
+      break;
+    case 'delete':
+      confirmDelete(dbInbound);
+      break;
+    case 'resetTraffic':
+      confirmResetTraffic(dbInbound);
+      break;
+    case 'clone':
+      confirmClone(dbInbound);
+      break;
+    case 'resetClients':
+      Modal.confirm({
+        title: `Reset client traffic on "${dbInbound.remark}"?`,
+        okText: 'Reset',
+        cancelText: 'Cancel',
+        onOk: async () => {
+          const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
+          if (msg?.success) await refresh();
+        },
+      });
+      break;
+    case 'delDepletedClients':
+      confirmDelDepleted(dbInbound.id);
+      break;
+    default:
+      message.info(`Action "${key}" — coming in a later 5f subphase`);
+  }
+}
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+              <!-- Summary statistics card -->
+              <a-col :span="24">
+                <a-card size="small" hoverable class="summary-card">
+                  <a-row :gutter="[16, 12]">
+                    <a-col :sm="12" :md="5">
+                      <CustomStatistic :title="t('pages.inbounds.totalDownUp')"
+                        :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
+                        <template #prefix>
+                          <SwapOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="5">
+                      <CustomStatistic :title="t('pages.inbounds.totalUsage')"
+                        :value="SizeFormatter.sizeFormat(totals.up + totals.down)">
+                        <template #prefix>
+                          <PieChartOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="5">
+                      <CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
+                        :value="SizeFormatter.sizeFormat(totals.allTime)">
+                        <template #prefix>
+                          <HistoryOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="5">
+                      <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
+                        <template #prefix>
+                          <BarsOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="24" :md="4">
+                      <CustomStatistic :title="t('clients')" value=" ">
+                        <template #prefix>
+                          <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-space>
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <!-- Inbound list — toolbar, search/filter, columns, row actions -->
+              <a-col :span="24">
+                <InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
+                  :last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark"
+                  :expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
+                  :sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @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" />
+              </a-col>
+            </a-row>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
+      <ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
+        :client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
+        :ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
+      <ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
+        :tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
+      <InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
+        :remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
+        :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" />
+
+      <TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
+      <PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
+        :initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.inbounds-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.inbounds-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+
+.inbounds-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.inbounds-page :deep(.ant-layout),
+.inbounds-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 8px;
+  }
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.summary-card {
+  padding: 16px;
+}
+
+@media (max-width: 768px) {
+  .summary-card {
+    padding: 8px;
+  }
+}
+</style>

+ 67 - 0
frontend/src/pages/inbounds/QrCodeModal.vue

@@ -0,0 +1,67 @@
+<script setup>
+import { 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: '' },
+});
+
+const emit = defineEmits(['update:open']);
+
+const links = ref([]);
+const wireguardConfigs = ref([]);
+const wireguardLinks = ref([]);
+
+watch(() => props.open, (next) => {
+  if (!next || !props.dbInbound) return;
+  const inbound = props.dbInbound.toInbound();
+  if (inbound.protocol === Protocols.WIREGUARD) {
+    const peerRemark = props.client?.email
+      ? `${props.dbInbound.remark}-${props.client.email}`
+      : props.dbInbound.remark;
+    wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
+    wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
+    links.value = [];
+  } else {
+    // When a client is provided we generate per-client share links;
+    // otherwise (single-user SS) fall back to the inbound's settings.
+    links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
+    wireguardConfigs.value = [];
+    wireguardLinks.value = [];
+  }
+});
+
+function close() {
+  emit('update:open', false);
+}
+</script>
+
+<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>
+    </template>
+  </a-modal>
+</template>

+ 158 - 0
frontend/src/pages/inbounds/QrPanel.vue

@@ -0,0 +1,158 @@
+<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';
+
+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'));
+}
+
+function download() {
+  if (!props.downloadName) return;
+  FileManager.downloadTextFile(props.value, props.downloadName);
+}
+</script>
+
+<template>
+  <div class="qr-panel">
+    <div class="qr-panel-header">
+      <a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
+      <a-tooltip :title="t('copy')">
+        <a-button size="small" @click="copy">
+          <template #icon>
+            <CopyOutlined />
+          </template>
+        </a-button>
+      </a-tooltip>
+      <a-tooltip v-if="downloadName" :title="t('download')">
+        <a-button size="small" @click="download">
+          <template #icon>
+            <DownloadOutlined />
+          </template>
+        </a-button>
+      </a-tooltip>
+    </div>
+    <div v-if="showQr" class="qr-panel-canvas">
+      <canvas
+        ref="canvas"
+        :style="{ width: `${size}px`, height: `${size}px` }"
+        @click="copy"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.qr-panel {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 8px;
+  padding: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.qr-panel-header {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  flex-wrap: wrap;
+}
+
+.qr-remark {
+  margin: 0;
+}
+
+.qr-panel-canvas {
+  display: flex;
+  justify-content: center;
+  padding: 6px 0;
+}
+
+.qr-panel-canvas canvas {
+  cursor: pointer;
+  display: block;
+  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>

+ 323 - 0
frontend/src/pages/inbounds/useInbounds.js

@@ -0,0 +1,323 @@
+// Loads the inbound list + sidecar data the page needs (online users,
+// last-online-map, default settings) and computes the per-inbound client
+// roll-ups the legacy panel surfaces in the popovers.
+//
+// Live-update model: initial GET on mount, then the WebSocket delta path
+// keeps the table fresh — the page subscribes to the server's `traffic`,
+// `client_stats`, and `invalidate` events and merges them into local
+// refs in-place. The manual refresh button is kept as a fallback.
+
+import { computed, ref, shallowRef } from 'vue';
+import { HttpUtil, ObjectUtil } from '@/utils';
+import { DBInbound } from '@/models/dbinbound.js';
+import { Protocols } from '@/models/inbound.js';
+import { setDatepicker } from '@/composables/useDatepicker.js';
+
+export function useInbounds() {
+  const fetched = ref(false);
+  const refreshing = ref(false);
+
+  // shallowRef because each refresh swaps the array; per-row reactivity is
+  // unnecessary at the page level (modals work on copies).
+  const dbInbounds = shallowRef([]);
+  const clientCount = ref({});
+  const onlineClients = ref([]);
+  const lastOnlineMap = ref({});
+
+  // Default-settings sidecar fields the table needs for color/expiry math.
+  const expireDiff = ref(0);
+  const trafficDiff = ref(0);
+  const subSettings = ref({
+    enable: false,
+    subTitle: '',
+    subURI: '',
+    subJsonURI: '',
+    subJsonEnable: false,
+  });
+  const remarkModel = ref('-ieo');
+  const datepicker = ref('gregorian');
+  const tgBotEnable = ref(false);
+  const ipLimitEnable = ref(false);
+  const pageSize = ref(0);
+
+  function isClientOnline(email) {
+    return onlineClients.value.includes(email);
+  }
+
+  // Roll-up of {clients, active, deactive, depleted, expiring, online,
+  // comments} for a single inbound. Mirrors getClientCounts in the legacy
+  // template. Skipped for protocols that don't have multi-user clients
+  // (HTTP, MIXED, WireGuard) since their settings have no client list.
+  function rollupClients(dbInbound, inbound) {
+    const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
+    const clients = inbound?.clients || [];
+    const active = [];
+    const deactive = [];
+    const depleted = [];
+    const expiring = [];
+    const online = [];
+    const comments = new Map();
+    const now = Date.now();
+
+    if (dbInbound.enable) {
+      for (const client of clients) {
+        if (client.comment) comments.set(client.email, client.comment);
+        if (client.enable) {
+          active.push(client.email);
+          if (isClientOnline(client.email)) online.push(client.email);
+        } else {
+          deactive.push(client.email);
+        }
+      }
+      for (const stats of clientStats) {
+        const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
+        const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
+        if (expired || exhausted) {
+          depleted.push(stats.email);
+        } else {
+          const expiringSoon =
+            (stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
+            (stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
+          if (expiringSoon) expiring.push(stats.email);
+        }
+      }
+    } else {
+      for (const client of clients) deactive.push(client.email);
+    }
+
+    return {
+      clients: clients.length,
+      active,
+      deactive,
+      depleted,
+      expiring,
+      online,
+      comments,
+    };
+  }
+
+  function setInbounds(rows) {
+    const next = [];
+    const counts = {};
+    for (const row of rows) {
+      const dbInbound = new DBInbound(row);
+      const parsed = dbInbound.toInbound();
+      next.push(dbInbound);
+      const tracked = [
+        Protocols.VMESS,
+        Protocols.VLESS,
+        Protocols.TROJAN,
+        Protocols.SHADOWSOCKS,
+        Protocols.HYSTERIA,
+      ];
+      if (tracked.includes(row.protocol)) {
+        if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
+        counts[row.id] = rollupClients(dbInbound, parsed);
+      }
+    }
+    dbInbounds.value = next;
+    clientCount.value = counts;
+    fetched.value = true;
+  }
+
+  async function fetchOnlineUsers() {
+    const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
+    if (msg?.success) onlineClients.value = msg.obj || [];
+  }
+
+  async function fetchLastOnlineMap() {
+    const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
+    if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
+  }
+
+  async function fetchDefaultSettings() {
+    const msg = await HttpUtil.post('/panel/setting/defaultSettings');
+    if (!msg?.success) return;
+    const s = msg.obj || {};
+    expireDiff.value = (s.expireDiff ?? 0) * 86400000;
+    trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
+    tgBotEnable.value = !!s.tgBotEnable;
+    subSettings.value = {
+      enable: !!s.subEnable,
+      subTitle: s.subTitle || '',
+      subURI: s.subURI || '',
+      subJsonURI: s.subJsonURI || '',
+      subJsonEnable: !!s.subJsonEnable,
+    };
+    pageSize.value = s.pageSize ?? 0;
+    remarkModel.value = s.remarkModel || '-ieo';
+    datepicker.value = s.datepicker || 'gregorian';
+    // Mirror into the global composable so date-pickers in modals can
+    // pick the right calendar without re-fetching the settings.
+    setDatepicker(datepicker.value);
+    ipLimitEnable.value = !!s.ipLimitEnable;
+  }
+
+  // ============ WebSocket live-update merge ===========================
+  // The xray traffic job and the node traffic sync job each broadcast
+  // a `traffic` payload every ~10s. We merge it into onlineClients +
+  // lastOnlineMap; per-inbound counters arrive in the parallel
+  // client_stats event below.
+  function applyTrafficEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (Array.isArray(payload.onlineClients)) {
+      onlineClients.value = payload.onlineClients;
+    }
+    if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
+      // Merge so a subsequent payload that drops a quiet client doesn't
+      // wipe their last-seen timestamp.
+      lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
+    }
+    // Recompute per-inbound rollups so the "online" badges in the
+    // expand-row table flip without waiting for a full refresh.
+    rebuildClientCount();
+  }
+
+  // The client_stats payload carries absolute traffic counters for the
+  // clients that had activity in the latest window plus per-inbound
+  // totals. Both are absolute (not deltas), so we overwrite in place.
+  function applyClientStatsEvent(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    let touched = false;
+
+    if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
+      const byId = new Map();
+      for (const row of payload.inbounds) {
+        if (row && row.id != null) byId.set(row.id, row);
+      }
+      for (const ib of dbInbounds.value) {
+        const upd = byId.get(ib.id);
+        if (!upd) continue;
+        if (typeof upd.up === 'number') ib.up = upd.up;
+        if (typeof upd.down === 'number') ib.down = upd.down;
+        if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
+        touched = true;
+      }
+    }
+
+    if (Array.isArray(payload.clients) && payload.clients.length > 0) {
+      const byEmail = new Map();
+      for (const row of payload.clients) {
+        if (row && row.email) byEmail.set(row.email, row);
+      }
+      for (const ib of dbInbounds.value) {
+        if (!Array.isArray(ib.clientStats)) continue;
+        for (let i = 0; i < ib.clientStats.length; i++) {
+          const stat = ib.clientStats[i];
+          const upd = byEmail.get(stat.email);
+          if (!upd) continue;
+          if (typeof upd.up === 'number') stat.up = upd.up;
+          if (typeof upd.down === 'number') stat.down = upd.down;
+          if (typeof upd.total === 'number') stat.total = upd.total;
+          if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
+          touched = true;
+        }
+      }
+    }
+
+    if (touched) {
+      // shallowRef → trigger reactivity by reassigning the same array.
+      dbInbounds.value = [...dbInbounds.value];
+      rebuildClientCount();
+    }
+  }
+
+  // The hub may decide a payload is too large to push directly and emit
+  // an `invalidate` event with the affected dataType instead. For the
+  // inbounds page that means "the inbound list changed elsewhere — go
+  // re-fetch via REST".
+  function applyInvalidate(payload) {
+    if (!payload || typeof payload !== 'object') return;
+    if (payload.dataType === 'inbounds') {
+      refresh();
+    }
+  }
+
+  // Recompute the per-inbound roll-up after any in-place mutation.
+  // Cheap because rollupClients only iterates a single inbound's
+  // clients + clientStats arrays.
+  function rebuildClientCount() {
+    const counts = {};
+    const tracked = [
+      Protocols.VMESS,
+      Protocols.VLESS,
+      Protocols.TROJAN,
+      Protocols.SHADOWSOCKS,
+      Protocols.HYSTERIA,
+    ];
+    for (const dbInbound of dbInbounds.value) {
+      const parsed = dbInbound.toInbound();
+      if (!tracked.includes(dbInbound.protocol)) continue;
+      if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
+      counts[dbInbound.id] = rollupClients(dbInbound, parsed);
+    }
+    clientCount.value = counts;
+  }
+
+  async function refresh() {
+    refreshing.value = true;
+    try {
+      const msg = await HttpUtil.get('/panel/api/inbounds/list');
+      if (!msg?.success) return;
+      await fetchLastOnlineMap();
+      await fetchOnlineUsers();
+      setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
+    } finally {
+      // Match legacy: keep the spinning-icon state visible briefly so
+      // a fast network doesn't make the button feel like it didn't fire.
+      setTimeout(() => { refreshing.value = false; }, 500);
+    }
+  }
+
+  // Aggregate totals shown in the dashboard summary card. allTime falls
+  // back to up+down when the per-inbound counter isn't populated yet.
+  const totals = computed(() => {
+    let up = 0;
+    let down = 0;
+    let allTime = 0;
+    let clients = 0;
+    const deactive = [];
+    const depleted = [];
+    const expiring = [];
+    for (const ib of dbInbounds.value) {
+      up += ib.up || 0;
+      down += ib.down || 0;
+      allTime += ib.allTime || (ib.up + ib.down) || 0;
+      const c = clientCount.value[ib.id];
+      if (c) {
+        clients += c.clients;
+        deactive.push(...c.deactive);
+        depleted.push(...c.depleted);
+        expiring.push(...c.expiring);
+      }
+    }
+    return { up, down, allTime, clients, deactive, depleted, expiring };
+  });
+
+  // ObjectUtil reference is wired at module load — keeping a no-op import
+  // here so the linter doesn't drop it; the legacy search uses it.
+  void ObjectUtil;
+
+  return {
+    fetched,
+    refreshing,
+    dbInbounds,
+    clientCount,
+    onlineClients,
+    lastOnlineMap,
+    totals,
+    expireDiff,
+    trafficDiff,
+    subSettings,
+    remarkModel,
+    datepicker,
+    tgBotEnable,
+    ipLimitEnable,
+    pageSize,
+    refresh,
+    fetchDefaultSettings,
+    applyTrafficEvent,
+    applyClientStatsEvent,
+    applyInvalidate,
+  };
+}

+ 101 - 0
frontend/src/pages/index/BackupModal.vue

@@ -0,0 +1,101 @@
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
+import { HttpUtil, PromiseUtil } from '@/utils';
+
+const { t } = useI18n();
+
+defineProps({
+  open: { type: Boolean, default: false },
+  basePath: { type: String, default: '' },
+});
+
+const emit = defineEmits(['update:open', 'busy']);
+
+function close() {
+  emit('update:open', false);
+}
+
+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';
+}
+
+function importDb() {
+  const fileInput = document.createElement('input');
+  fileInput.type = 'file';
+  fileInput.accept = '.db';
+  fileInput.addEventListener('change', async (e) => {
+    const dbFile = e.target.files?.[0];
+    if (!dbFile) return;
+
+    const formData = new FormData();
+    formData.append('db', dbFile);
+
+    close();
+    emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
+
+    const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    });
+    if (!upload?.success) {
+      emit('busy', { busy: false });
+      return;
+    }
+
+    emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
+    const restart = await HttpUtil.post('/panel/setting/restartPanel');
+    if (restart?.success) {
+      await PromiseUtil.sleep(5000);
+      window.location.reload();
+    } else {
+      emit('busy', { busy: false });
+    }
+  });
+  fileInput.click();
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
+    <a-list bordered class="backup-list">
+      <a-list-item class="backup-item">
+        <a-list-item-meta>
+          <template #title>{{ t('pages.index.exportDatabase') }}</template>
+          <template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
+        </a-list-item-meta>
+        <a-button type="primary" @click="exportDb">
+          <template #icon>
+            <DownloadOutlined />
+          </template>
+        </a-button>
+      </a-list-item>
+
+      <a-list-item class="backup-item">
+        <a-list-item-meta>
+          <template #title>{{ t('pages.index.importDatabase') }}</template>
+          <template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
+        </a-list-item-meta>
+        <a-button type="primary" @click="importDb">
+          <template #icon>
+            <UploadOutlined />
+          </template>
+        </a-button>
+      </a-list-item>
+    </a-list>
+  </a-modal>
+</template>
+
+<style scoped>
+.backup-list {
+  width: 100%;
+}
+
+.backup-item {
+  display: flex;
+  align-items: center;
+  gap: 16px;
+}
+</style>

+ 106 - 0
frontend/src/pages/index/CustomGeoFormModal.vue

@@ -0,0 +1,106 @@
+<script setup>
+import { reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+import { HttpUtil } from '@/utils';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  // Populate with the record when editing; null/undefined when adding.
+  record: { type: Object, default: null },
+});
+
+const emit = defineEmits(['update:open', 'saved']);
+
+const form = reactive({ type: 'geosite', alias: '', url: '' });
+const saving = ref(false);
+
+const editing = ref(false);
+const editId = ref(null);
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (props.record) {
+    editing.value = true;
+    editId.value = props.record.id;
+    form.type = props.record.type;
+    form.alias = props.record.alias;
+    form.url = props.record.url;
+  } else {
+    editing.value = false;
+    editId.value = null;
+    form.type = 'geosite';
+    form.alias = '';
+    form.url = '';
+  }
+});
+
+function close() {
+  emit('update:open', false);
+}
+
+function validate() {
+  // Backend expects a filesystem-safe alias; legacy enforces the same regex.
+  if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
+    message.error(t('pages.index.customGeoValidationAlias'));
+    return false;
+  }
+  const u = (form.url || '').trim();
+  if (!/^https?:\/\//i.test(u)) {
+    message.error(t('pages.index.customGeoValidationUrl'));
+    return false;
+  }
+  try {
+    const parsed = new URL(u);
+    if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+      message.error(t('pages.index.customGeoValidationUrl'));
+      return false;
+    }
+  } catch (_e) {
+    message.error(t('pages.index.customGeoValidationUrl'));
+    return false;
+  }
+  return true;
+}
+
+async function submit() {
+  if (!validate()) return;
+  saving.value = true;
+  try {
+    const url = editing.value
+      ? `/panel/api/custom-geo/update/${editId.value}`
+      : '/panel/api/custom-geo/add';
+    const msg = await HttpUtil.post(url, form);
+    if (msg?.success) {
+      emit('saved');
+      close();
+    }
+  } finally {
+    saving.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
+    :confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
+    @cancel="close">
+    <a-form layout="vertical">
+      <a-form-item :label="t('pages.index.customGeoType')">
+        <a-select v-model:value="form.type" :disabled="editing">
+          <a-select-option value="geosite">geosite</a-select-option>
+          <a-select-option value="geoip">geoip</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item :label="t('pages.index.customGeoAlias')">
+        <a-input v-model:value="form.alias" :disabled="editing"
+          :placeholder="t('pages.index.customGeoAliasPlaceholder')" />
+      </a-form-item>
+      <a-form-item :label="t('pages.index.customGeoUrl')">
+        <a-input v-model:value="form.url" placeholder="https://" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 311 - 0
frontend/src/pages/index/CustomGeoSection.vue

@@ -0,0 +1,311 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  PlusOutlined,
+  ReloadOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  InboxOutlined,
+} from '@ant-design/icons-vue';
+
+import { HttpUtil, ClipboardManager } from '@/utils';
+import CustomGeoFormModal from './CustomGeoFormModal.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  // Re-fetch the list when the parent collapse expands this section.
+  active: { type: Boolean, default: false },
+});
+
+const list = ref([]);
+const loading = ref(false);
+const updatingAll = ref(false);
+const actionId = ref(null);
+
+const formOpen = ref(false);
+const editingRecord = ref(null);
+
+// Computed so column titles re-render after a locale swap.
+const columns = computed(() => [
+  { title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
+  { title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
+  { title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
+  { title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
+  { title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
+]);
+
+async function loadList() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/custom-geo/list');
+    if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function openAdd() {
+  editingRecord.value = null;
+  formOpen.value = true;
+}
+
+function openEdit(record) {
+  editingRecord.value = record;
+  formOpen.value = true;
+}
+
+function extDisplay(record) {
+  const fn = record.type === 'geoip'
+    ? `geoip_${record.alias}.dat`
+    : `geosite_${record.alias}.dat`;
+  return `ext:${fn}:tag`;
+}
+
+async function copyExt(record) {
+  const text = extDisplay(record);
+  const ok = await ClipboardManager.copyText(text);
+  if (ok) message.success(`${t('copied')}: ${text}`);
+}
+
+function formatTime(ts) {
+  if (!ts) return '';
+  const d = new Date(ts * 1000);
+  if (isNaN(d.getTime())) return String(ts);
+  const pad = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
+}
+
+// Tiny inline relative-time formatter so we don't pull in moment.
+function relativeTime(ts) {
+  if (!ts) return '';
+  const diff = Math.floor(Date.now() / 1000) - ts;
+  if (diff < 60) return 'just now';
+  if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
+  if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
+  if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
+  return formatTime(ts);
+}
+
+function confirmDelete(record) {
+  Modal.confirm({
+    title: t('pages.index.customGeoDelete'),
+    content: t('pages.index.customGeoDeleteConfirm'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
+      if (msg?.success) await loadList();
+    },
+  });
+}
+
+async function downloadOne(id) {
+  actionId.value = id;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
+    if (msg?.success) await loadList();
+  } finally {
+    actionId.value = null;
+  }
+}
+
+async function updateAll() {
+  updatingAll.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
+    const ok = msg?.obj?.succeeded?.length || 0;
+    const failed = msg?.obj?.failed?.length || 0;
+    if (msg?.success || ok > 0) {
+      await loadList();
+      if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
+    }
+  } finally {
+    updatingAll.value = false;
+  }
+}
+
+// Lazy-load: only fetch when the parent collapse opens this panel.
+watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
+</script>
+
+<template>
+  <div class="custom-geo-section">
+    <a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
+
+    <div class="toolbar">
+      <a-button type="primary" :loading="loading" @click="openAdd">
+        <template #icon>
+          <PlusOutlined />
+        </template>
+        {{ t('pages.index.customGeoAdd') }}
+      </a-button>
+      <a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
+        <template #icon>
+          <ReloadOutlined />
+        </template>
+        {{ t('pages.index.geofilesUpdateAll') }}
+      </a-button>
+      <span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
+    </div>
+
+    <a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
+      size="small" :scroll="{ x: 760 }">
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'alias'">
+          <div class="custom-geo-alias-cell">
+            <a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
+              {{ record.type }}
+            </a-tag>
+            <span class="custom-geo-alias">{{ record.alias }}</span>
+          </div>
+        </template>
+
+        <template v-else-if="column.key === 'url'">
+          <a-tooltip placement="topLeft" :title="record.url">
+            <a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
+              {{ record.url }}
+            </a>
+          </a-tooltip>
+        </template>
+
+        <template v-else-if="column.key === 'extDat'">
+          <a-tooltip :title="t('copy')">
+            <code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
+              {{ extDisplay(record) }}
+            </code>
+          </a-tooltip>
+        </template>
+
+        <template v-else-if="column.key === 'lastUpdatedAt'">
+          <a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
+            <span>{{ relativeTime(record.lastUpdatedAt) }}</span>
+          </a-tooltip>
+          <span v-else class="custom-geo-muted">—</span>
+        </template>
+
+        <template v-else-if="column.key === 'action'">
+          <a-space size="small">
+            <a-tooltip :title="t('pages.index.customGeoEdit')">
+              <a-button type="link" size="small" @click="openEdit(record)">
+                <template #icon>
+                  <EditOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+            <a-tooltip :title="t('pages.index.customGeoDownload')">
+              <a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
+                <template #icon>
+                  <ReloadOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+            <a-tooltip :title="t('pages.index.customGeoDelete')">
+              <a-button type="link" size="small" danger @click="confirmDelete(record)">
+                <template #icon>
+                  <DeleteOutlined />
+                </template>
+              </a-button>
+            </a-tooltip>
+          </a-space>
+        </template>
+      </template>
+
+      <template #emptyText>
+        <div class="custom-geo-empty">
+          <InboxOutlined class="custom-geo-empty-icon" />
+          <div>{{ t('pages.index.customGeoEmpty') }}</div>
+        </div>
+      </template>
+    </a-table>
+
+    <CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
+  </div>
+</template>
+
+<style scoped>
+.mb-10 {
+  margin-bottom: 10px;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-bottom: 10px;
+}
+
+.custom-geo-count {
+  margin-left: 4px;
+  padding: 2px 8px;
+  border-radius: 10px;
+  background: rgba(0, 0, 0, 0.05);
+  font-size: 12px;
+  opacity: 0.75;
+}
+
+:global(body.dark) .custom-geo-count {
+  background: rgba(255, 255, 255, 0.08);
+}
+
+.custom-geo-alias-cell {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.custom-geo-alias {
+  font-weight: 500;
+  word-break: break-all;
+}
+
+.custom-geo-type-tag {
+  margin: 0;
+}
+
+.custom-geo-url {
+  word-break: break-all;
+}
+
+.custom-geo-ext-code {
+  cursor: pointer;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  background: rgba(0, 0, 0, 0.05);
+  user-select: all;
+}
+
+.custom-geo-copyable:hover {
+  background: rgba(0, 0, 0, 0.1);
+}
+
+:global(body.dark) .custom-geo-ext-code {
+  background: rgba(255, 255, 255, 0.08);
+}
+
+:global(body.dark) .custom-geo-copyable:hover {
+  background: rgba(255, 255, 255, 0.14);
+}
+
+.custom-geo-muted {
+  opacity: 0.5;
+}
+
+.custom-geo-empty {
+  text-align: center;
+  padding: 18px 0;
+  opacity: 0.6;
+}
+
+.custom-geo-empty-icon {
+  font-size: 32px;
+  margin-bottom: 6px;
+  display: block;
+}
+</style>

+ 420 - 0
frontend/src/pages/index/IndexPage.vue

@@ -0,0 +1,420 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  BarsOutlined,
+  ControlOutlined,
+  CloudServerOutlined,
+  CloudDownloadOutlined,
+  CloudUploadOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+  AreaChartOutlined,
+  GlobalOutlined,
+  SwapOutlined,
+  EyeOutlined,
+  EyeInvisibleOutlined,
+} from '@ant-design/icons-vue';
+
+const { t } = useI18n();
+
+import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useStatus } from '@/composables/useStatus.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import CustomStatistic from '@/components/CustomStatistic.vue';
+import TextModal from '@/components/TextModal.vue';
+import StatusCard from './StatusCard.vue';
+import XrayStatusCard from './XrayStatusCard.vue';
+import PanelUpdateModal from './PanelUpdateModal.vue';
+import LogModal from './LogModal.vue';
+import BackupModal from './BackupModal.vue';
+import SystemHistoryModal from './SystemHistoryModal.vue';
+import XrayLogModal from './XrayLogModal.vue';
+import VersionModal from './VersionModal.vue';
+
+const { status, fetched, refresh } = useStatus();
+const { isMobile } = useMediaQuery();
+
+// `/panel/setting/defaultSettings` returns ipLimitEnable; the xray
+// card hides its log button when access logs are off.
+const ipLimitEnable = ref(false);
+HttpUtil.post('/panel/setting/defaultSettings').then((msg) => {
+  if (msg?.success && msg.obj) ipLimitEnable.value = !!msg.obj.ipLimitEnable;
+});
+
+// Panel-update info — fetched once on mount, drives both the badge
+// in QuickActions and the contents of PanelUpdateModal.
+const panelUpdateInfo = ref({ currentVersion: '', latestVersion: '', updateAvailable: false });
+onMounted(() => {
+  HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+    if (msg?.success && msg.obj) panelUpdateInfo.value = msg.obj;
+  });
+});
+
+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 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__ || '?',
+);
+
+// Hide/reveal the public IPv4/IPv6 — same pattern as legacy.
+const showIp = ref(false);
+
+// Modal open state.
+const logsOpen = ref(false);
+const backupOpen = ref(false);
+const panelUpdateOpen = ref(false);
+const sysHistoryOpen = ref(false);
+const xrayLogsOpen = ref(false);
+const versionOpen = ref(false);
+const configTextOpen = ref(false);
+const configText = ref('');
+
+// Page-level loading overlay; modals can request it via @busy.
+const loading = ref(false);
+const loadingTip = ref(t('loading'));
+function setBusy({ busy, tip }) {
+  loading.value = busy;
+  if (tip) loadingTip.value = tip;
+}
+
+// Xray controls
+async function stopXray() {
+  await HttpUtil.post('/panel/api/server/stopXrayService');
+  await refresh();
+}
+async function restartXray() {
+  await HttpUtil.post('/panel/api/server/restartXrayService');
+  await refresh();
+}
+
+function openSystemHistory() { sysHistoryOpen.value = true; }
+function openXrayLogs() { xrayLogsOpen.value = true; }
+function openVersionSwitch() { versionOpen.value = true; }
+
+// Legacy "Config" action — fetch the rendered xray config and show
+// it as JSON in the shared TextModal (same UX as main).
+async function openConfig() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/server/getConfigJson');
+    if (!msg?.success) return;
+    configText.value = JSON.stringify(msg.obj, null, 2);
+    configTextOpen.value = true;
+  } finally {
+    loading.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="index-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content class="content-area">
+          <a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
+              <a-col :span="24">
+                <StatusCard :status="status" :is-mobile="isMobile" />
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <XrayStatusCard :status="status" :is-mobile="isMobile" :ip-limit-enable="ipLimitEnable"
+                  @stop-xray="stopXray" @restart-xray="restartXray" @open-xray-logs="openXrayLogs"
+                  @open-logs="logsOpen = true" @open-version-switch="openVersionSwitch" />
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('menu.link')" hoverable>
+                  <template #actions>
+                    <a-space class="action" @click="logsOpen = true">
+                      <BarsOutlined />
+                      <span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
+                    </a-space>
+                    <a-space class="action" @click="openConfig">
+                      <ControlOutlined />
+                      <span v-if="!isMobile">{{ t('pages.index.config') }}</span>
+                    </a-space>
+                    <a-space class="action" @click="backupOpen = true">
+                      <CloudServerOutlined />
+                      <span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
+                    </a-space>
+                  </template>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card title="3X-UI" hoverable>
+                  <template v-if="panelUpdateInfo.updateAvailable" #extra>
+                    <a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
+                      <a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
+                        <CloudDownloadOutlined />
+                        {{ panelUpdateInfo.latestVersion }}
+                        <span v-if="!isMobile">{{ t('pages.index.updatePanel') }}</span>
+                      </a-tag>
+                    </a-tooltip>
+                  </template>
+                  <div class="link-tags">
+                    <a href="https://github.com/MHSanaei/3x-ui/releases" target="_blank" rel="noopener noreferrer">
+                      <a-tag color="green">v{{ displayVersion }}</a-tag>
+                    </a>
+                    <a href="https://t.me/XrayUI" target="_blank" rel="noopener noreferrer">
+                      <a-tag color="green">@XrayUI</a-tag>
+                    </a>
+                    <a href="https://github.com/MHSanaei/3x-ui/wiki" target="_blank" rel="noopener noreferrer">
+                      <a-tag color="purple">{{ t('pages.index.documentation') }}</a-tag>
+                    </a>
+                  </div>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.operationHours')" hoverable>
+                  <a-tag :color="status.xray.color">
+                    Xray: {{ TimeFormatter.formatSecond(status.appStats.uptime) }}
+                  </a-tag>
+                  <a-tag color="green">OS: {{ TimeFormatter.formatSecond(status.uptime) }}</a-tag>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.systemLoad')" hoverable>
+                  <template #extra>
+                    <a-tag color="blue" class="history-tag" @click="openSystemHistory">
+                      <AreaChartOutlined />
+                      {{ t('pages.index.systemHistoryTitle') }}
+                    </a-tag>
+                  </template>
+                  <a-tooltip :title="t('pages.index.systemLoadDesc')">
+                    <a-tag color="green">
+                      {{ status.loads[0] }} | {{ status.loads[1] }} | {{ status.loads[2] }}
+                    </a-tag>
+                  </a-tooltip>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('usage')" hoverable>
+                  <a-tag color="green">
+                    {{ t('pages.index.memory') }}: {{ SizeFormatter.sizeFormat(status.appStats.mem) }}
+                  </a-tag>
+                  <a-tag color="green">
+                    {{ t('pages.index.threads') }}: {{ status.appStats.threads }}
+                  </a-tag>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.overallSpeed')" hoverable>
+                  <a-row :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="12">
+                      <CustomStatistic :title="t('pages.index.upload')"
+                        :value="SizeFormatter.sizeFormat(status.netIO.up)">
+                        <template #prefix>
+                          <ArrowUpOutlined />
+                        </template>
+                        <template #suffix>/s</template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :span="12">
+                      <CustomStatistic :title="t('pages.index.download')"
+                        :value="SizeFormatter.sizeFormat(status.netIO.down)">
+                        <template #prefix>
+                          <ArrowDownOutlined />
+                        </template>
+                        <template #suffix>/s</template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.totalData')" hoverable>
+                  <a-row :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="12">
+                      <CustomStatistic :title="t('pages.index.sent')"
+                        :value="SizeFormatter.sizeFormat(status.netTraffic.sent)">
+                        <template #prefix>
+                          <CloudUploadOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :span="12">
+                      <CustomStatistic :title="t('pages.index.received')"
+                        :value="SizeFormatter.sizeFormat(status.netTraffic.recv)">
+                        <template #prefix>
+                          <CloudDownloadOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.ipAddresses')" hoverable>
+                  <template #extra>
+                    <a-tooltip :title="t('pages.index.toggleIpVisibility')" :placement="isMobile ? 'topRight' : 'top'">
+                      <component :is="showIp ? EyeOutlined : EyeInvisibleOutlined" class="ip-toggle-icon"
+                        @click="showIp = !showIp" />
+                    </a-tooltip>
+                  </template>
+                  <a-row :class="showIp ? 'ip-visible' : 'ip-hidden'" :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="isMobile ? 24 : 12">
+                      <CustomStatistic title="IPv4" :value="status.publicIP.ipv4">
+                        <template #prefix>
+                          <GlobalOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :span="isMobile ? 24 : 12">
+                      <CustomStatistic title="IPv6" :value="status.publicIP.ipv6">
+                        <template #prefix>
+                          <GlobalOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <a-col :xs="24" :lg="12">
+                <a-card :title="t('pages.index.connectionCount')" hoverable>
+                  <a-row :gutter="isMobile ? [8, 8] : 0">
+                    <a-col :span="12">
+                      <CustomStatistic title="TCP" :value="status.tcpCount">
+                        <template #prefix>
+                          <SwapOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :span="12">
+                      <CustomStatistic title="UDP" :value="status.udpCount">
+                        <template #prefix>
+                          <SwapOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+            </a-row>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <PanelUpdateModal v-model:open="panelUpdateOpen" :info="panelUpdateInfo" @busy="setBusy" />
+      <LogModal v-model:open="logsOpen" />
+      <BackupModal v-model:open="backupOpen" :base-path="basePath" @busy="setBusy" />
+      <SystemHistoryModal v-model:open="sysHistoryOpen" :status="status" />
+      <XrayLogModal v-model:open="xrayLogsOpen" />
+      <VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
+      <TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
+        file-name="config.json" />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.index-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.index-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+
+.index-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.index-page :deep(.ant-layout),
+.index-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 12px;
+    padding-top: 64px;
+  }
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.action {
+  cursor: pointer;
+  justify-content: center;
+}
+
+.update-tag {
+  cursor: pointer;
+  margin: 0;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.history-tag {
+  cursor: pointer;
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  margin-inline-end: 0;
+}
+
+.link-tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.link-tags a {
+  display: inline-flex;
+}
+
+.link-tags :deep(.ant-tag) {
+  margin-inline-end: 0;
+}
+
+.ip-toggle-icon {
+  cursor: pointer;
+  font-size: 16px;
+}
+
+.ip-hidden :deep(.ant-statistic-content-value) {
+  filter: blur(6px);
+  transition: filter 0.2s ease;
+}
+
+.ip-visible :deep(.ant-statistic-content-value) {
+  filter: none;
+}
+</style>

+ 165 - 0
frontend/src/pages/index/LogModal.vue

@@ -0,0 +1,165 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
+
+import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open']);
+
+const rows = ref('20');
+const level = ref('info');
+const syslog = ref(false);
+const loading = ref(false);
+const logs = ref([]);
+
+const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
+const LEVEL_COLORS = ['#3c89e8', '#008771', '#008771', '#f37b24', '#e04141', '#bcbcbc'];
+
+function escapeHtml(value) {
+  if (value == null) return '';
+  return String(value)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+function formatLogs(lines) {
+  // Each line: "YYYY-MM-DD HH:MM:SS LEVEL - message"
+  // Color the timestamp + level prefix and bold the originating service.
+  let out = '';
+  lines.forEach((log, idx) => {
+    const [data, message] = log.split(' - ', 2);
+    const parts = data.split(' ');
+    if (idx > 0) out += '<br>';
+
+    if (parts.length === 3) {
+      const d = escapeHtml(parts[0]);
+      const t = escapeHtml(parts[1]);
+      const levelRaw = parts[2];
+      const li = LEVELS.indexOf(levelRaw);
+      const levelIndex = li >= 0 ? li : 5;
+      out += `<span style="color: ${LEVEL_COLORS[0]};">${d} ${t}</span> `;
+      out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(levelRaw)}</span>`;
+    } else {
+      const li = LEVELS.indexOf(data);
+      const levelIndex = li >= 0 ? li : 5;
+      out += `<span style="color: ${LEVEL_COLORS[levelIndex]}">${escapeHtml(data)}</span>`;
+    }
+
+    if (message) {
+      const prefix = message.startsWith('XRAY:') ? '<b>XRAY: </b>' : '<b>X-UI: </b>';
+      const tail = message.startsWith('XRAY:') ? message.substring(5) : message;
+      out += ' - ' + prefix + escapeHtml(tail);
+    }
+  });
+  return out;
+}
+
+const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
+
+async function refresh() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/server/logs/${rows.value}`, {
+      level: level.value,
+      syslog: syslog.value,
+    });
+    if (msg?.success) {
+      logs.value = msg.obj || [];
+    }
+    // Keep the spinner visible long enough that rapid filter changes
+    // feel intentional rather than flickery.
+    await PromiseUtil.sleep(300);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+function download() {
+  FileManager.downloadTextFile(logs.value.join('\n'), 'x-ui.log');
+}
+
+// Re-fetch whenever the modal opens or any filter changes.
+watch(() => props.open, (next) => { if (next) refresh(); });
+watch([rows, level, syslog], () => { if (props.open) refresh(); });
+</script>
+
+<template>
+  <a-modal :open="open" :closable="true" :footer="null" width="800px" @cancel="close">
+    <template #title>
+      {{ t('pages.index.logs') }}
+      <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
+    </template>
+
+    <a-form layout="inline">
+      <a-form-item>
+        <a-input-group compact>
+          <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
+            <a-select-option value="10">10</a-select-option>
+            <a-select-option value="20">20</a-select-option>
+            <a-select-option value="50">50</a-select-option>
+            <a-select-option value="100">100</a-select-option>
+            <a-select-option value="500">500</a-select-option>
+          </a-select>
+          <a-select v-model:value="level" size="small" :style="{ width: '95px' }">
+            <a-select-option value="debug">Debug</a-select-option>
+            <a-select-option value="info">Info</a-select-option>
+            <a-select-option value="notice">Notice</a-select-option>
+            <a-select-option value="warning">Warning</a-select-option>
+            <a-select-option value="err">Error</a-select-option>
+          </a-select>
+        </a-input-group>
+      </a-form-item>
+      <a-form-item>
+        <a-checkbox v-model:checked="syslog">SysLog</a-checkbox>
+      </a-form-item>
+      <a-form-item style="margin-left: auto">
+        <a-button type="primary" @click="download">
+          <template #icon>
+            <DownloadOutlined />
+          </template>
+        </a-button>
+      </a-form-item>
+    </a-form>
+
+    <div class="log-container" v-html="formattedLogs" />
+  </a-modal>
+</template>
+
+<style scoped>
+.reload-icon {
+  cursor: pointer;
+  vertical-align: middle;
+  margin-left: 10px;
+}
+
+.log-container {
+  margin-top: 12px;
+  padding: 10px 12px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-word;
+  max-height: 60vh;
+  overflow-y: auto;
+  border: 1px solid rgba(128, 128, 128, 0.25);
+  border-radius: 6px;
+  background: rgba(0, 0, 0, 0.04);
+}
+
+:global(body.dark) .log-container {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+</style>

+ 112 - 0
frontend/src/pages/index/PanelUpdateModal.vue

@@ -0,0 +1,112 @@
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import { CloudDownloadOutlined } from '@ant-design/icons-vue';
+import { HttpUtil, PromiseUtil } from '@/utils';
+import axios from 'axios';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  info: {
+    type: Object,
+    default: () => ({ currentVersion: '', latestVersion: '', updateAvailable: false }),
+  },
+});
+
+const emit = defineEmits(['update:open', 'busy']);
+
+function close() {
+  emit('update:open', false);
+}
+
+function updatePanel() {
+  Modal.confirm({
+    title: t('pages.index.panelUpdateDialog'),
+    content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
+    okText: t('confirm'),
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const baseTip = t('pages.index.dontRefresh');
+      const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
+      close();
+      emit('busy', { busy: true, tip });
+      const msg = await HttpUtil.post('/panel/api/server/updatePanel');
+      if (!msg?.success) {
+        emit('busy', { busy: false });
+        return;
+      }
+      // Wait for the running process to exit, then poll the new panel
+      // until it answers (up to ~90s). Reload as soon as it's back.
+      await PromiseUtil.sleep(5000);
+      const deadline = Date.now() + 90_000;
+      let back = false;
+      while (Date.now() < deadline) {
+        try {
+          const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
+          if (r?.data?.success) { back = true; break; }
+        } catch (_) { /* still restarting */ }
+        await PromiseUtil.sleep(2000);
+      }
+      if (back) {
+        message.success(t('pages.index.panelUpdateStartedPopover'));
+        await PromiseUtil.sleep(800);
+      }
+      window.location.reload();
+    },
+  });
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
+    <a-alert v-if="info.updateAvailable" type="warning" class="mb-12" :message="t('pages.index.panelUpdateDesc')"
+      show-icon />
+
+    <a-list bordered class="version-list">
+      <a-list-item class="version-list-item">
+        <span>{{ t('pages.index.currentPanelVersion') }}</span>
+        <a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
+      </a-list-item>
+      <a-list-item v-if="info.updateAvailable" class="version-list-item">
+        <span>{{ t('pages.index.latestPanelVersion') }}</span>
+        <a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
+      </a-list-item>
+      <a-list-item v-else class="version-list-item">
+        <span>{{ t('pages.index.panelUpToDate') }}</span>
+        <a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
+      </a-list-item>
+    </a-list>
+
+    <div class="actions-row">
+      <a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
+        <template #icon>
+          <CloudDownloadOutlined />
+        </template>
+        {{ t('pages.index.updatePanel') }}
+      </a-button>
+    </div>
+  </a-modal>
+</template>
+
+<style scoped>
+.mb-12 {
+  margin-bottom: 12px;
+}
+
+.version-list {
+  width: 100%;
+}
+
+.version-list-item {
+  display: flex;
+  justify-content: space-between;
+}
+
+.actions-row {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+</style>

+ 96 - 0
frontend/src/pages/index/StatusCard.vue

@@ -0,0 +1,96 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { AreaChartOutlined } from '@ant-design/icons-vue';
+
+import { CPUFormatter, SizeFormatter } from '@/utils';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  status: { type: Object, required: true },
+  isMobile: { type: Boolean, default: false },
+});
+
+// AD-Vue's default 120px dashboard renders the percent text at ~36px
+// which dwarfs the rest of the card. 70 (60 on mobile) plus the
+// :deep(.ant-progress-text) override below keep the gauges compact.
+const gaugeSize = computed(() => (props.isMobile ? 60 : 70));
+
+// AD-Vue's default unfinished trail (rgba(0,0,0,0.06) /
+// rgba(255,255,255,0.08)) is invisible against the light card; a
+// neutral mid-gray reads on both themes.
+const trailColor = 'rgba(128, 128, 128, 0.25)';
+</script>
+
+<template>
+  <a-card hoverable>
+    <a-row :gutter="[0, isMobile ? 16 : 0]">
+      <!-- CPU + Memory -->
+      <a-col :xs="24" :md="12">
+        <a-row>
+          <a-col :span="12" class="text-center">
+            <a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
+              :trail-color="trailColor" :percent="status.cpu.percent" :width="gaugeSize" />
+            <div>
+              <b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
+              <a-tooltip>
+                <template #title>
+                  <div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
+                  <div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}
+                  </div>
+                </template>
+                <AreaChartOutlined />
+              </a-tooltip>
+            </div>
+          </a-col>
+
+          <a-col :span="12" class="text-center">
+            <a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
+              :trail-color="trailColor" :percent="status.mem.percent" :width="gaugeSize" />
+            <div>
+              <b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
+              {{ SizeFormatter.sizeFormat(status.mem.total) }}
+            </div>
+          </a-col>
+        </a-row>
+      </a-col>
+
+      <!-- Swap + Disk -->
+      <a-col :xs="24" :md="12">
+        <a-row>
+          <a-col :span="12" class="text-center">
+            <a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
+              :trail-color="trailColor" :percent="status.swap.percent" :width="gaugeSize" />
+            <div>
+              <b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
+              {{ SizeFormatter.sizeFormat(status.swap.total) }}
+            </div>
+          </a-col>
+
+          <a-col :span="12" class="text-center">
+            <a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
+              :trail-color="trailColor" :percent="status.disk.percent" :width="gaugeSize" />
+            <div>
+              <b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
+              {{ SizeFormatter.sizeFormat(status.disk.total) }}
+            </div>
+          </a-col>
+        </a-row>
+      </a-col>
+    </a-row>
+  </a-card>
+</template>
+
+<style scoped>
+.text-center {
+  text-align: center;
+}
+
+/* Pin the percent number to a label-sized 14px — AD-Vue scales it
+ * from the SVG's intrinsic size, so :width alone leaves it too big. */
+:deep(.ant-progress-text) {
+  font-size: 14px !important;
+  font-weight: 500;
+}
+</style>

+ 163 - 0
frontend/src/pages/index/SystemHistoryModal.vue

@@ -0,0 +1,163 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { HttpUtil, SizeFormatter } from '@/utils';
+import Sparkline from '@/components/Sparkline.vue';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+
+const { t } = useI18n();
+const { isMobile } = useMediaQuery();
+const modalWidth = computed(() => (isMobile.value ? '95vw' : '900px'));
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  status: { type: Object, required: true },
+});
+
+const emit = defineEmits(['update:open']);
+
+// One tab per system metric. The order here drives the tab order in
+// the UI; everything else (axis label, tooltip unit, fetch URL) is
+// looked up from the active key. Adding another metric is one row.
+const metrics = [
+  { key: 'cpu', tab: 'CPU', valueMax: 100, unit: '%', stroke: '' },
+  { key: 'mem', tab: 'RAM', valueMax: 100, unit: '%', stroke: '#7c4dff' },
+  { key: 'netUp', tab: 'Net Up', valueMax: null, unit: 'B/s', stroke: '#1890ff' },
+  { key: 'netDown', tab: 'Net Down', valueMax: null, unit: 'B/s', stroke: '#13c2c2' },
+  { key: 'online', tab: 'Online', valueMax: null, unit: '', stroke: '#52c41a' },
+  { key: 'load1', tab: 'Load 1m', valueMax: null, unit: '', stroke: '#fa8c16' },
+  { key: 'load5', tab: 'Load 5m', valueMax: null, unit: '', stroke: '#f5222d' },
+  { key: 'load15', tab: 'Load 15m', valueMax: null, unit: '', stroke: '#a0d911' },
+];
+
+const activeKey = ref('cpu');
+const bucket = ref(2);
+const points = ref([]);
+const labels = ref([]);
+
+const activeMetric = computed(() => metrics.find((m) => m.key === activeKey.value));
+
+// CPU keeps using the status-card color so the modal visually echoes
+// the dot in StatusCard. Non-CPU tabs each get their own constant color.
+const strokeColor = computed(() => {
+  const m = activeMetric.value;
+  if (m?.stroke) return m.stroke;
+  return props.status?.cpu?.color || '#008771';
+});
+
+function unitFormatter(unit) {
+  if (unit === 'B/s') {
+    return (v) => `${SizeFormatter.sizeFormat(Math.max(0, Number(v) || 0))}/s`;
+  }
+  if (unit === '%') {
+    return (v) => `${Number(v).toFixed(1)}%`;
+  }
+  // Plain numbers: load averages get two decimals, online client count
+  // is integer. Heuristic on the unit-less metric key is good enough.
+  return (v) => {
+    const n = Number(v) || 0;
+    if (activeKey.value === 'online') return String(Math.round(n));
+    return n.toFixed(2);
+  };
+}
+
+const yFormatter = computed(() => unitFormatter(activeMetric.value?.unit ?? ''));
+
+async function fetchBucket() {
+  const m = activeMetric.value;
+  if (!m) return;
+  try {
+    const url = `/panel/api/server/history/${m.key}/${bucket.value}`;
+    const msg = await HttpUtil.get(url);
+    if (msg?.success && Array.isArray(msg.obj)) {
+      const vals = [];
+      const labs = [];
+      for (const p of msg.obj) {
+        const d = new Date(p.t * 1000);
+        const hh = String(d.getHours()).padStart(2, '0');
+        const mm = String(d.getMinutes()).padStart(2, '0');
+        const ss = String(d.getSeconds()).padStart(2, '0');
+        labs.push(bucket.value >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`);
+        vals.push(Number(p.v) || 0);
+      }
+      labels.value = labs;
+      points.value = vals;
+    } else {
+      labels.value = [];
+      points.value = [];
+    }
+  } catch (e) {
+    console.error('Failed to fetch history bucket', e);
+    labels.value = [];
+    points.value = [];
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+watch(() => props.open, (next) => {
+  if (next) {
+    activeKey.value = 'cpu';
+    fetchBucket();
+  }
+});
+watch([activeKey, bucket], () => {
+  if (props.open) fetchBucket();
+});
+</script>
+
+<template>
+  <a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" @cancel="close">
+    <template #title>
+      {{ t('pages.index.systemHistoryTitle') }}
+      <a-select v-model:value="bucket" size="small" class="bucket-select">
+        <a-select-option :value="2">2m</a-select-option>
+        <a-select-option :value="30">30m</a-select-option>
+        <a-select-option :value="60">1h</a-select-option>
+        <a-select-option :value="120">2h</a-select-option>
+        <a-select-option :value="180">3h</a-select-option>
+        <a-select-option :value="300">5h</a-select-option>
+      </a-select>
+    </template>
+
+    <a-tabs v-model:active-key="activeKey" size="small" class="history-tabs">
+      <a-tab-pane v-for="m in metrics" :key="m.key" :tab="m.tab" />
+    </a-tabs>
+
+    <div class="cpu-chart-wrap">
+      <div class="cpu-chart-meta">
+        Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
+      </div>
+      <Sparkline :data="points" :labels="labels" :vb-width="840" :height="220"
+        :stroke="strokeColor" :stroke-width="2.2"
+        :show-grid="true" :show-axes="true" :tick-count-x="5"
+        :max-points="points.length || 1"
+        :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true"
+        :value-min="0" :value-max="activeMetric?.valueMax ?? null"
+        :y-formatter="yFormatter" />
+    </div>
+  </a-modal>
+</template>
+
+<style scoped>
+.bucket-select {
+  width: 80px;
+  margin-left: 10px;
+}
+
+.history-tabs {
+  margin-bottom: 4px;
+}
+
+.cpu-chart-wrap {
+  padding: 8px 16px 16px;
+}
+
+.cpu-chart-meta {
+  margin-bottom: 10px;
+  font-size: 11px;
+  opacity: 0.65;
+}
+</style>

+ 147 - 0
frontend/src/pages/index/VersionModal.vue

@@ -0,0 +1,147 @@
+<script setup>
+import { ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal } from 'ant-design-vue';
+import { ReloadOutlined } from '@ant-design/icons-vue';
+import { HttpUtil } from '@/utils';
+import CustomGeoSection from './CustomGeoSection.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  status: { type: Object, required: true },
+});
+
+const emit = defineEmits(['update:open', 'busy']);
+
+const activeKey = ref('1');
+const versions = ref([]);
+const loading = ref(false);
+
+// Geofiles list is hardcoded in the legacy panel — same set of files
+// served from /panel/api/server/updateGeofile/{name}.
+const GEOFILES = ['geosite.dat', 'geoip.dat', 'geosite_IR.dat', 'geoip_IR.dat', 'geosite_RU.dat', 'geoip_RU.dat'];
+
+async function fetchVersions() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
+    if (msg?.success) versions.value = msg.obj || [];
+  } finally {
+    loading.value = false;
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+function switchXrayVersion(version) {
+  Modal.confirm({
+    title: t('pages.index.xraySwitchVersionDialog'),
+    content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
+    okText: t('confirm'),
+    cancelText: t('cancel'),
+    onOk: async () => {
+      close();
+      emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
+      try {
+        await HttpUtil.post(`/panel/api/server/installXray/${version}`);
+      } finally {
+        emit('busy', { busy: false });
+      }
+    },
+  });
+}
+
+function updateGeofile(fileName) {
+  const isSingle = !!fileName;
+  Modal.confirm({
+    title: t('pages.index.geofileUpdateDialog'),
+    content: isSingle
+      ? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
+      : t('pages.index.geofilesUpdateDialogDesc'),
+    okText: t('confirm'),
+    cancelText: t('cancel'),
+    onOk: async () => {
+      close();
+      emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
+      const url = isSingle
+        ? `/panel/api/server/updateGeofile/${fileName}`
+        : '/panel/api/server/updateGeofile';
+      try {
+        await HttpUtil.post(url);
+      } finally {
+        emit('busy', { busy: false });
+      }
+    },
+  });
+}
+
+watch(() => props.open, (next) => { if (next) fetchVersions(); });
+</script>
+
+<template>
+  <a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
+    <a-spin :spinning="loading">
+      <a-collapse v-model:active-key="activeKey" accordion>
+        <a-collapse-panel key="1" header="Xray">
+          <a-alert type="warning" class="mb-12" :message="t('pages.index.xraySwitchClickDesk')" show-icon />
+          <a-list bordered class="version-list">
+            <a-list-item v-for="(version, index) in versions" :key="version" class="version-list-item">
+              <a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ version }}</a-tag>
+              <a-radio :checked="version === `v${status?.xray?.version}`" @click="switchXrayVersion(version)" />
+            </a-list-item>
+          </a-list>
+        </a-collapse-panel>
+
+        <a-collapse-panel key="2" header="Geofiles">
+          <a-list bordered class="version-list">
+            <a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
+              <a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
+              <a-tooltip :title="t('update')">
+                <ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
+              </a-tooltip>
+            </a-list-item>
+          </a-list>
+          <div class="actions-row">
+            <a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
+          </div>
+        </a-collapse-panel>
+
+        <a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
+          <CustomGeoSection :active="activeKey === '3'" />
+        </a-collapse-panel>
+      </a-collapse>
+    </a-spin>
+  </a-modal>
+</template>
+
+<style scoped>
+.mb-12 {
+  margin-bottom: 12px;
+}
+
+.version-list {
+  width: 100%;
+}
+
+.version-list-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.reload-icon {
+  cursor: pointer;
+  font-size: 16px;
+  margin-right: 8px;
+}
+
+.actions-row {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 12px;
+}
+</style>

+ 182 - 0
frontend/src/pages/index/XrayLogModal.vue

@@ -0,0 +1,182 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
+
+import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
+import { useDatepicker } from '@/composables/useDatepicker.js';
+
+const { t } = useI18n();
+const { datepicker } = useDatepicker();
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open']);
+
+const rows = ref('20');
+const filter = ref('');
+const showDirect = ref(true);
+const showBlocked = ref(true);
+const showProxy = ref(true);
+const loading = ref(false);
+const logs = ref([]);
+
+function escapeHtml(value) {
+  if (value == null) return '';
+  return String(value)
+    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+}
+
+// Renders a `<table>` with one row per log entry. Event 1 = blocked
+// (red); Event 2 = proxy (blue); Event 0 = direct.
+function formatLogs(lines) {
+  let out = '<table class="xraylog-table"><tr>'
+    + '<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>'
+    + '</tr>';
+
+  // Reverse a copy — the legacy code mutated state with `.reverse()`.
+  [...lines].reverse().forEach((log) => {
+    let rowStyle = '';
+    if (log.Event === 1) rowStyle = ' style="color: #e04141;"';
+    else if (log.Event === 2) rowStyle = ' style="color: #3c89e8;"';
+
+    const emailCell = log.Email ? `<td>${escapeHtml(log.Email)}</td>` : '<td></td>';
+
+    out += `<tr${rowStyle}>`
+      + `<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime, datepicker.value))}</b></td>`
+      + `<td>${escapeHtml(log.FromAddress)}</td>`
+      + `<td>${escapeHtml(log.ToAddress)}</td>`
+      + `<td>${escapeHtml(log.Inbound)}</td>`
+      + `<td>${escapeHtml(log.Outbound)}</td>`
+      + emailCell
+      + '</tr>';
+  });
+
+  return out + '</table>';
+}
+
+const formattedLogs = computed(() => (logs.value.length > 0 ? formatLogs(logs.value) : 'No Record...'));
+
+async function refresh() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post(`/panel/api/server/xraylogs/${rows.value}`, {
+      filter: filter.value,
+      showDirect: showDirect.value,
+      showBlocked: showBlocked.value,
+      showProxy: showProxy.value,
+    });
+    if (msg?.success) logs.value = msg.obj || [];
+    await PromiseUtil.sleep(300);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function close() {
+  emit('update:open', false);
+}
+
+function download() {
+  if (!Array.isArray(logs.value) || logs.value.length === 0) {
+    FileManager.downloadTextFile('', 'x-ui.log');
+    return;
+  }
+  const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
+  const lines = logs.value.map((l) => {
+    try {
+      const dt = l.DateTime ? new Date(l.DateTime) : null;
+      const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
+      const eventText = eventMap[l.Event] || String(l.Event ?? '');
+      const emailPart = l.Email ? ` Email=${l.Email}` : '';
+      return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
+    } catch (_e) {
+      return JSON.stringify(l);
+    }
+  }).join('\n');
+  FileManager.downloadTextFile(lines, 'x-ui.log');
+}
+
+watch(() => props.open, (next) => { if (next) refresh(); });
+watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refresh(); });
+</script>
+
+<template>
+  <a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close">
+    <template #title>
+      {{ t('pages.index.logs') }}
+      <SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
+    </template>
+
+    <a-form layout="inline">
+      <a-form-item>
+        <a-select v-model:value="rows" size="small" :style="{ width: '70px' }">
+          <a-select-option value="10">10</a-select-option>
+          <a-select-option value="20">20</a-select-option>
+          <a-select-option value="50">50</a-select-option>
+          <a-select-option value="100">100</a-select-option>
+          <a-select-option value="500">500</a-select-option>
+        </a-select>
+      </a-form-item>
+      <a-form-item :label="t('filter')">
+        <a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
+      </a-form-item>
+      <a-form-item>
+        <a-checkbox v-model:checked="showDirect">Direct</a-checkbox>
+        <a-checkbox v-model:checked="showBlocked">Blocked</a-checkbox>
+        <a-checkbox v-model:checked="showProxy">Proxy</a-checkbox>
+      </a-form-item>
+      <a-form-item style="margin-left: auto">
+        <a-button type="primary" @click="download">
+          <template #icon>
+            <DownloadOutlined />
+          </template>
+        </a-button>
+      </a-form-item>
+    </a-form>
+
+    <div class="log-container" v-html="formattedLogs" />
+  </a-modal>
+</template>
+
+<style scoped>
+.reload-icon {
+  cursor: pointer;
+  vertical-align: middle;
+  margin-left: 10px;
+}
+
+.log-container {
+  margin-top: 12px;
+  padding: 10px 12px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+  line-height: 1.5;
+  max-height: 60vh;
+  overflow: auto;
+  border: 1px solid rgba(128, 128, 128, 0.25);
+  border-radius: 6px;
+  background: rgba(0, 0, 0, 0.04);
+}
+
+:global(body.dark) .log-container {
+  background: rgba(255, 255, 255, 0.03);
+  border-color: rgba(255, 255, 255, 0.1);
+}
+</style>
+
+<style>
+/* Global so the v-html'd table picks up these styles. */
+.xraylog-table {
+  border-collapse: collapse;
+  width: auto;
+}
+
+.xraylog-table td,
+.xraylog-table th {
+  padding: 2px 15px;
+}
+</style>

+ 144 - 0
frontend/src/pages/index/XrayStatusCard.vue

@@ -0,0 +1,144 @@
+<script setup>
+import { useI18n } from 'vue-i18n';
+import {
+  BarsOutlined,
+  PoweroffOutlined,
+  ReloadOutlined,
+  ToolOutlined,
+} from '@ant-design/icons-vue';
+
+const { t } = useI18n();
+
+defineProps({
+  status: { type: Object, required: true },
+  isMobile: { type: Boolean, default: false },
+  ipLimitEnable: { type: Boolean, default: false },
+});
+
+defineEmits(['stop-xray', 'restart-xray', 'open-logs', 'open-xray-logs', 'open-version-switch']);
+
+// Map xray.color → which animation class to apply on the badge dot.
+// The legacy .xray-*-animation classes only override the badge ring
+// color; the actual pulsing comes from .xray-processing-animation
+// (which animates .ant-badge-status-dot via @keyframes runningAnimation).
+function badgeAnimationClass(color) {
+  if (color === 'green') return 'xray-running-animation';
+  if (color === 'orange') return 'xray-stop-animation';
+  if (color === 'red') return 'xray-error-animation';
+  return 'xray-processing-animation';
+}
+</script>
+
+<template>
+  <a-card hoverable>
+    <template #title>
+      <a-space direction="horizontal">
+        <span>{{ t('pages.index.xrayStatus') }}</span>
+        <a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
+          v{{ status.xray.version }}
+        </a-tag>
+      </a-space>
+    </template>
+
+    <template #extra>
+      <template v-if="status.xray.state !== 'error'">
+        <a-badge status="processing" :class="['xray-processing-animation', badgeAnimationClass(status.xray.color)]"
+          :text="status.xray.stateMsg" :color="status.xray.color" />
+      </template>
+      <template v-else>
+        <a-popover>
+          <template #title>
+            <a-row type="flex" align="middle" justify="space-between">
+              <a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
+              <a-col>
+                <BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
+              </a-col>
+            </a-row>
+          </template>
+          <template #content>
+            <span v-for="(line, i) in (status.xray.errorMsg || '').split('\n')" :key="i" class="error-line">
+              {{ line }}
+            </span>
+          </template>
+          <a-badge status="processing" :text="status.xray.stateMsg" :color="status.xray.color"
+            :class="['xray-processing-animation', 'xray-error-animation']" />
+        </a-popover>
+      </template>
+    </template>
+
+    <template #actions>
+      <a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
+        <BarsOutlined />
+        <span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
+      </a-space>
+      <a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
+        <PoweroffOutlined />
+        <span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
+      </a-space>
+      <a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
+        <ReloadOutlined />
+        <span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
+      </a-space>
+      <a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
+        <ToolOutlined />
+        <span v-if="!isMobile">
+          {{ status.xray.version && status.xray.version !== 'Unknown'
+            ? `v${status.xray.version}`
+            : t('pages.index.xraySwitch') }}
+        </span>
+      </a-space>
+    </template>
+  </a-card>
+</template>
+
+<style scoped>
+.action {
+  cursor: pointer;
+  justify-content: center;
+}
+
+.error-line {
+  display: block;
+  max-width: 400px;
+  white-space: pre-wrap;
+}
+
+.cursor-pointer {
+  cursor: pointer;
+}
+</style>
+
+<style>
+/* Legacy xray-*-animation classes — they need to be global so they
+ * pierce the AD-Vue badge's internal DOM (.ant-badge-status-*). */
+.xray-processing-animation .ant-badge-status-dot {
+  animation: xray-pulse 1.2s linear infinite;
+}
+
+.xray-running-animation .ant-badge-status-processing::after {
+  border-color: #1677ff;
+}
+
+.xray-stop-animation .ant-badge-status-processing::after {
+  border-color: #fa8c16;
+}
+
+.xray-error-animation .ant-badge-status-processing::after {
+  border-color: #f5222d;
+}
+
+@keyframes xray-pulse {
+
+  0%,
+  50%,
+  100% {
+    transform: scale(1);
+    opacity: 1;
+  }
+
+  10% {
+    transform: scale(1.5);
+    opacity: 0.2;
+  }
+}
+</style>

+ 350 - 0
frontend/src/pages/login/LoginPage.vue

@@ -0,0 +1,350 @@
+<script setup>
+import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { UserOutlined, LockOutlined, KeyOutlined, SettingOutlined } from '@ant-design/icons-vue';
+
+import { HttpUtil, LanguageManager } from '@/utils';
+import {
+  antdThemeConfig,
+  currentTheme,
+  theme as themeState,
+} from '@/composables/useTheme.js';
+import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
+
+const { t } = useI18n();
+
+const headlineWords = computed(() => [t('pages.login.hello'), t('pages.login.title')]);
+const HEADLINE_INTERVAL_MS = 2000;
+const headlineIndex = ref(0);
+let headlineTimer = null;
+
+onMounted(() => {
+  headlineTimer = window.setInterval(() => {
+    headlineIndex.value = (headlineIndex.value + 1) % headlineWords.value.length;
+  }, HEADLINE_INTERVAL_MS);
+});
+
+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;
+  }
+  fetched.value = true;
+});
+
+async function login() {
+  submitting.value = true;
+  try {
+    const msg = await HttpUtil.post('/login', user);
+    if (msg.success) {
+      window.location.href = basePath + 'panel/';
+    }
+  } finally {
+    submitting.value = false;
+  }
+}
+
+const lang = ref(LanguageManager.getLanguage());
+function onLangChange(next) {
+  LanguageManager.setLanguage(next);
+}
+</script>
+
+<template>
+  <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>
+        </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 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>
+          </a-col>
+        </a-row>
+      </a-layout-content>
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.login-app {
+  --bg-page: #c7ebe2;
+  --bg-wave-header: #dbf5ed;
+  --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;
+
+  min-height: 100vh;
+}
+
+.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;
+}
+
+.login-app.is-dark.is-ultra {
+  --bg-page: #0f2d32;
+  --bg-wave-header: #0a2227;
+  --bg-card: #0c0e12;
+  --wave-fill: #1f4d52;
+  --wave-fill-bottom: #0f2d32;
+}
+
+.login-app,
+.login-app :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.login-app {
+  background: var(--bg-page);
+}
+
+.login-card {
+  background: var(--bg-card);
+  box-shadow: var(--shadow-card);
+}
+
+.login-title {
+  color: var(--color-title);
+}
+
+.login-settings {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 8px;
+}
+
+.settings-popover {
+  min-width: 220px;
+}
+
+.lang-select {
+  width: 100%;
+}
+
+.login-content {
+  position: relative;
+}
+
+.login-row {
+  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-loading {
+  text-align: center;
+  padding: 40px 0;
+}
+
+.login-title {
+  text-align: center;
+  margin-bottom: 32px;
+  font-size: 2rem;
+  font-weight: 500;
+  min-height: 2.5rem;
+}
+
+.login-title b {
+  display: inline-block;
+}
+
+.headline-enter-active,
+.headline-leave-active {
+  transition: opacity 0.4s ease, transform 0.4s ease;
+}
+
+.headline-enter-from {
+  opacity: 0;
+  transform: translateY(-12px);
+}
+
+.headline-leave-to {
+  opacity: 0;
+  transform: translateY(12px);
+}
+
+.waves-header {
+  position: fixed;
+  inset: 0 0 auto 0;
+  width: 100%;
+  z-index: 0;
+  pointer-events: none;
+  background: var(--bg-wave-header);
+}
+
+.waves-inner-header {
+  height: 50vh;
+  width: 100%;
+}
+
+.waves {
+  position: relative;
+  display: block;
+  width: 100%;
+  height: 15vh;
+  min-height: 100px;
+  max-height: 150px;
+  margin-bottom: -8px;
+}
+
+.parallax>use {
+  fill: var(--wave-fill);
+  animation: move-forever 25s cubic-bezier(0.55, 0.5, 0.45, 0.5) infinite;
+}
+
+.parallax>use:nth-child(1) {
+  animation-delay: -2s;
+  animation-duration: 4s;
+  opacity: 0.2;
+}
+
+.parallax>use:nth-child(2) {
+  animation-delay: -3s;
+  animation-duration: 7s;
+  opacity: 0.4;
+}
+
+.parallax>use:nth-child(3) {
+  animation-delay: -4s;
+  animation-duration: 10s;
+  opacity: 0.6;
+}
+
+.parallax>use:nth-child(4) {
+  animation-delay: -5s;
+  animation-duration: 13s;
+  fill: var(--wave-fill-bottom);
+  opacity: 1;
+}
+
+@keyframes move-forever {
+  0% {
+    transform: translate3d(-90px, 0, 0);
+  }
+
+  100% {
+    transform: translate3d(85px, 0, 0);
+  }
+}
+</style>

+ 223 - 0
frontend/src/pages/nodes/NodeFormModal.vue

@@ -0,0 +1,223 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  mode: { type: String, default: 'add' }, // 'add' | 'edit'
+  node: { type: Object, default: null },
+  testConnection: { type: Function, required: true },
+  save: { type: Function, required: true }, // (payload) => Promise<msg>
+});
+
+const emit = defineEmits(['update:open']);
+
+const { t } = useI18n();
+
+// Default form shape — used for "add" mode and to reset between
+// edits. Sane defaults: HTTPS, port 2053, base path '/', enabled.
+function defaultForm() {
+  return {
+    id: 0,
+    name: '',
+    remark: '',
+    scheme: 'https',
+    address: '',
+    port: 2053,
+    basePath: '/',
+    apiToken: '',
+    enable: true,
+  };
+}
+
+const form = reactive(defaultForm());
+const submitting = ref(false);
+const testing = ref(false);
+const testResult = ref(null); // { status, latencyMs, xrayVersion, error }
+// Reset the form whenever the modal is opened. In edit mode we copy
+// the existing node into the form fields; in add mode we wipe back
+// to defaults so a previous edit doesn't leak through.
+watch(
+  () => props.open,
+  (open) => {
+    if (!open) return;
+    Object.assign(form, defaultForm());
+    testResult.value = null;
+    if (props.mode === 'edit' && props.node) {
+      Object.assign(form, props.node);
+    }
+  },
+);
+
+const title = computed(() =>
+  props.mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode'),
+);
+
+function close() {
+  if (!submitting.value) emit('update:open', false);
+}
+
+function buildPayload() {
+  return {
+    id: form.id || 0,
+    name: form.name?.trim() || '',
+    remark: form.remark?.trim() || '',
+    scheme: form.scheme || 'https',
+    address: form.address?.trim() || '',
+    port: Number(form.port) || 0,
+    basePath: form.basePath?.trim() || '/',
+    apiToken: form.apiToken?.trim() || '',
+    enable: !!form.enable,
+  };
+}
+
+async function onTest() {
+  testing.value = true;
+  testResult.value = null;
+  try {
+    const payload = buildPayload();
+    if (!payload.address || !payload.port) {
+      message.error(t('pages.nodes.toasts.fillRequired'));
+      return;
+    }
+    const msg = await props.testConnection(payload);
+    if (msg?.success) {
+      testResult.value = msg.obj;
+    } else {
+      testResult.value = { status: 'offline', error: msg?.msg || 'unknown error' };
+    }
+  } finally {
+    testing.value = false;
+  }
+}
+
+async function onSave() {
+  const payload = buildPayload();
+  if (!payload.name || !payload.address || !payload.port) {
+    message.error(t('pages.nodes.toasts.fillRequired'));
+    return;
+  }
+  submitting.value = true;
+  try {
+    const msg = await props.save(payload);
+    if (msg?.success) {
+      emit('update:open', false);
+    }
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    :title="title"
+    :confirm-loading="submitting"
+    :ok-text="t('save')"
+    :cancel-text="t('cancel')"
+    :mask-closable="false"
+    width="640px"
+    @ok="onSave"
+    @cancel="close"
+  >
+    <a-form layout="vertical" :model="form">
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.nodes.name')" required>
+            <a-input v-model:value="form.name" :placeholder="t('pages.nodes.namePlaceholder')" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.nodes.remark')">
+            <a-input v-model:value="form.remark" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="6">
+          <a-form-item :label="t('pages.nodes.scheme')">
+            <a-select v-model:value="form.scheme">
+              <a-select-option value="https">https</a-select-option>
+              <a-select-option value="http">http</a-select-option>
+            </a-select>
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.nodes.address')" required>
+            <a-input v-model:value="form.address" :placeholder="t('pages.nodes.addressPlaceholder')" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="6">
+          <a-form-item :label="t('pages.nodes.port')" required>
+            <a-input-number v-model:value="form.port" :min="1" :max="65535" style="width: 100%" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-row :gutter="16">
+        <a-col :span="12">
+          <a-form-item :label="t('pages.nodes.basePath')">
+            <a-input v-model:value="form.basePath" placeholder="/" />
+          </a-form-item>
+        </a-col>
+        <a-col :span="12">
+          <a-form-item :label="t('pages.nodes.enable')">
+            <a-switch v-model:checked="form.enable" />
+          </a-form-item>
+        </a-col>
+      </a-row>
+
+      <a-form-item :label="t('pages.nodes.apiToken')" required>
+        <a-input-password
+          v-model:value="form.apiToken"
+          :placeholder="t('pages.nodes.apiTokenPlaceholder')"
+        />
+        <div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
+      </a-form-item>
+
+      <div class="test-row">
+        <a-button :loading="testing" @click="onTest">
+          {{ t('pages.nodes.testConnection') }}
+        </a-button>
+        <div v-if="testResult" class="test-result">
+          <a-alert
+            v-if="testResult.status === 'online'"
+            type="success"
+            show-icon
+            :message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
+            :description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined"
+          />
+          <a-alert
+            v-else
+            type="error"
+            show-icon
+            :message="t('pages.nodes.connectionFailed')"
+            :description="testResult.error"
+          />
+        </div>
+      </div>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.hint {
+  font-size: 12px;
+  opacity: 0.6;
+  margin-top: 4px;
+}
+
+.test-row {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  margin-top: 8px;
+}
+
+.test-result {
+  width: 100%;
+}
+</style>

+ 134 - 0
frontend/src/pages/nodes/NodeHistoryPanel.vue

@@ -0,0 +1,134 @@
+<script setup>
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { HttpUtil } from '@/utils';
+import Sparkline from '@/components/Sparkline.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  node: { type: Object, required: true },
+  // Bucket size in seconds — matches the SystemHistoryModal selector.
+  bucket: { type: Number, default: 30 },
+});
+
+// Two parallel series so the panel renders CPU and Mem side-by-side
+// in a single fetch round-trip per refresh.
+const cpuPoints = ref([]);
+const cpuLabels = ref([]);
+const memPoints = ref([]);
+const memLabels = ref([]);
+
+const REFRESH_MS = 15000;
+let timer = null;
+
+function bucketLabel(unixSec) {
+  const d = new Date(unixSec * 1000);
+  const hh = String(d.getHours()).padStart(2, '0');
+  const mm = String(d.getMinutes()).padStart(2, '0');
+  if (props.bucket >= 60) return `${hh}:${mm}`;
+  const ss = String(d.getSeconds()).padStart(2, '0');
+  return `${hh}:${mm}:${ss}`;
+}
+
+async function fetchSeries(metric) {
+  try {
+    const url = `/panel/api/nodes/history/${props.node.id}/${metric}/${props.bucket}`;
+    const msg = await HttpUtil.get(url);
+    if (msg?.success && Array.isArray(msg.obj)) {
+      const vals = [];
+      const labs = [];
+      for (const p of msg.obj) {
+        labs.push(bucketLabel(p.t));
+        vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
+      }
+      return { vals, labs };
+    }
+  } catch (e) {
+    console.error('node history fetch failed', metric, e);
+  }
+  return { vals: [], labs: [] };
+}
+
+async function refresh() {
+  const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
+  cpuPoints.value = cpu.vals;
+  cpuLabels.value = cpu.labs;
+  memPoints.value = mem.vals;
+  memLabels.value = mem.labs;
+}
+
+onMounted(() => {
+  refresh();
+  timer = window.setInterval(refresh, REFRESH_MS);
+});
+
+onBeforeUnmount(() => {
+  if (timer != null) window.clearInterval(timer);
+});
+
+// If the parent table re-emits a node row with a different id (rare —
+// happens when the list is sorted or filtered while the panel is open),
+// reset and re-fetch.
+watch(() => props.node?.id, (a, b) => {
+  if (a !== b) refresh();
+});
+</script>
+
+<template>
+  <div class="node-history-panel">
+    <div class="series">
+      <div class="series-title">{{ t('pages.nodes.cpu') }}</div>
+      <Sparkline
+        :data="cpuPoints"
+        :labels="cpuLabels"
+        :vb-width="640" :height="120"
+        stroke="#008771"
+        :show-grid="true" :show-axes="true"
+        :tick-count-x="4"
+        :max-points="cpuPoints.length || 1"
+        :fill-opacity="0.18"
+        :marker-radius="2.6"
+        :show-tooltip="true"
+      />
+    </div>
+    <div class="series">
+      <div class="series-title">{{ t('pages.nodes.mem') }}</div>
+      <Sparkline
+        :data="memPoints"
+        :labels="memLabels"
+        :vb-width="640" :height="120"
+        stroke="#7c4dff"
+        :show-grid="true" :show-axes="true"
+        :tick-count-x="4"
+        :max-points="memPoints.length || 1"
+        :fill-opacity="0.18"
+        :marker-radius="2.6"
+        :show-tooltip="true"
+      />
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.node-history-panel {
+  padding: 8px 0;
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+}
+
+@media (max-width: 768px) {
+  .node-history-panel {
+    grid-template-columns: 1fr;
+    gap: 12px;
+  }
+}
+
+.series-title {
+  font-size: 12px;
+  font-weight: 500;
+  opacity: 0.75;
+  margin-bottom: 4px;
+}
+</style>

+ 207 - 0
frontend/src/pages/nodes/NodeList.vue

@@ -0,0 +1,207 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  EditOutlined,
+  DeleteOutlined,
+  PlusOutlined,
+  ThunderboltOutlined,
+  ExclamationCircleOutlined,
+} from '@ant-design/icons-vue';
+import NodeHistoryPanel from './NodeHistoryPanel.vue';
+
+const props = defineProps({
+  nodes: { type: Array, default: () => [] },
+  loading: { type: Boolean, default: false },
+  isMobile: { type: Boolean, default: false },
+});
+
+const emit = defineEmits([
+  'add',
+  'edit',
+  'delete',
+  'probe',
+  'toggle-enable',
+]);
+
+const { t } = useI18n();
+
+// Render the address column as a clickable URL so admins can jump to
+// the remote panel directly from the list.
+const dataSource = computed(() =>
+  props.nodes.map((n) => ({
+    ...n,
+    url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
+    key: n.id,
+  })),
+);
+
+function statusColor(status) {
+  switch (status) {
+    case 'online': return 'green';
+    case 'offline': return 'red';
+    default: return 'default';
+  }
+}
+
+// Relative-time formatter — keeps the column compact and avoids
+// pulling dayjs just for this single use.
+function relativeTime(unixSeconds) {
+  if (!unixSeconds) return t('pages.nodes.never');
+  const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
+  if (diffSec < 5) return t('pages.nodes.justNow');
+  if (diffSec < 60) return `${diffSec}s`;
+  if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
+  if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
+  return `${Math.floor(diffSec / 86400)}d`;
+}
+
+function formatUptime(secs) {
+  if (!secs) return '-';
+  const days = Math.floor(secs / 86400);
+  const hours = Math.floor((secs % 86400) / 3600);
+  if (days > 0) return `${days}d ${hours}h`;
+  const mins = Math.floor((secs % 3600) / 60);
+  if (hours > 0) return `${hours}h ${mins}m`;
+  return `${mins}m`;
+}
+
+function formatPct(p) {
+  if (typeof p !== 'number' || isNaN(p)) return '-';
+  return `${p.toFixed(1)}%`;
+}
+</script>
+
+<template>
+  <a-card size="small" hoverable>
+    <div class="toolbar">
+      <a-button type="primary" @click="emit('add')">
+        <template #icon><PlusOutlined /></template>
+        {{ t('pages.nodes.addNode') }}
+      </a-button>
+    </div>
+
+    <a-table
+      :data-source="dataSource"
+      :pagination="false"
+      :loading="loading"
+      :scroll="{ x: 'max-content' }"
+      size="middle"
+      row-key="id"
+    >
+      <template #expandedRowRender="{ record }">
+        <NodeHistoryPanel :node="record" />
+      </template>
+      <a-table-column :title="t('pages.nodes.name')" data-index="name" :ellipsis="true">
+        <template #default="{ record }">
+          <div class="name-cell">
+            <span class="name">{{ record.name }}</span>
+            <span v-if="record.remark" class="remark">{{ record.remark }}</span>
+          </div>
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.address')" data-index="url" :ellipsis="true">
+        <template #default="{ record }">
+          <a :href="record.url" target="_blank" rel="noopener noreferrer">{{ record.url }}</a>
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
+        <template #default="{ record }">
+          <a-space :size="4">
+            <a-badge :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
+            <span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
+            <a-tooltip v-if="record.lastError" :title="record.lastError">
+              <ExclamationCircleOutlined style="color: #faad14" />
+            </a-tooltip>
+          </a-space>
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.cpu')" data-index="cpuPct" align="center" :width="90">
+        <template #default="{ record }">{{ formatPct(record.cpuPct) }}</template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.mem')" data-index="memPct" align="center" :width="90">
+        <template #default="{ record }">{{ formatPct(record.memPct) }}</template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.xrayVersion')" data-index="xrayVersion" align="center">
+        <template #default="{ record }">
+          {{ record.xrayVersion || '-' }}
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.uptime')" data-index="uptimeSecs" align="center">
+        <template #default="{ record }">{{ formatUptime(record.uptimeSecs) }}</template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.latency')" data-index="latencyMs" align="center" :width="100">
+        <template #default="{ record }">
+          <span v-if="record.latencyMs > 0">{{ record.latencyMs }} ms</span>
+          <span v-else>-</span>
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.lastHeartbeat')" data-index="lastHeartbeat" align="center" :width="120">
+        <template #default="{ record }">{{ relativeTime(record.lastHeartbeat) }}</template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
+        <template #default="{ record }">
+          <a-switch
+            :checked="record.enable"
+            size="small"
+            @change="(v) => emit('toggle-enable', record, v)"
+          />
+        </template>
+      </a-table-column>
+
+      <a-table-column :title="t('pages.nodes.actions')" align="center" :width="160" fixed="right">
+        <template #default="{ record }">
+          <a-space>
+            <a-tooltip :title="t('pages.nodes.probe')">
+              <a-button type="text" size="small" @click="emit('probe', record)">
+                <template #icon><ThunderboltOutlined /></template>
+              </a-button>
+            </a-tooltip>
+            <a-tooltip :title="t('edit')">
+              <a-button type="text" size="small" @click="emit('edit', record)">
+                <template #icon><EditOutlined /></template>
+              </a-button>
+            </a-tooltip>
+            <a-tooltip :title="t('delete')">
+              <a-button type="text" size="small" danger @click="emit('delete', record)">
+                <template #icon><DeleteOutlined /></template>
+              </a-button>
+            </a-tooltip>
+          </a-space>
+        </template>
+      </a-table-column>
+    </a-table>
+  </a-card>
+</template>
+
+<style scoped>
+.toolbar {
+  margin-bottom: 12px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.name-cell {
+  display: flex;
+  flex-direction: column;
+}
+
+.name {
+  font-weight: 500;
+}
+
+.remark {
+  font-size: 12px;
+  opacity: 0.65;
+}
+</style>

+ 243 - 0
frontend/src/pages/nodes/NodesPage.vue

@@ -0,0 +1,243 @@
+<script setup>
+import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  CloudServerOutlined,
+  CheckCircleOutlined,
+  CloseCircleOutlined,
+  ThunderboltOutlined,
+} from '@ant-design/icons-vue';
+
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import CustomStatistic from '@/components/CustomStatistic.vue';
+import NodeList from './NodeList.vue';
+import NodeFormModal from './NodeFormModal.vue';
+import { useNodes } from './useNodes.js';
+import { useWebSocket } from '@/composables/useWebSocket.js';
+
+const { t } = useI18n();
+
+const {
+  nodes,
+  loading,
+  fetched,
+  totals,
+  applyNodesEvent,
+  create,
+  update,
+  remove,
+  setEnable,
+  testConnection,
+  probe,
+} = useNodes();
+
+// Live updates — NodeHeartbeatJob pushes the fresh list every 10s.
+useWebSocket({ nodes: applyNodesEvent });
+
+const { isMobile } = useMediaQuery();
+
+const basePath = window.__X_UI_BASE_PATH__ || '';
+const requestUri = window.location.pathname;
+
+// === Form modal state =================================================
+const formOpen = ref(false);
+const formMode = ref('add');
+const formNode = ref(null);
+
+function onAdd() {
+  formMode.value = 'add';
+  formNode.value = null;
+  formOpen.value = true;
+}
+
+function onEdit(node) {
+  formMode.value = 'edit';
+  formNode.value = { ...node };
+  formOpen.value = true;
+}
+
+// Save callback the modal hands its payload to. We hide the create vs.
+// update branching here so the modal stays mode-agnostic.
+async function onSave(payload) {
+  if (formMode.value === 'edit' && formNode.value?.id) {
+    return update(formNode.value.id, payload);
+  }
+  return create(payload);
+}
+
+function onDelete(node) {
+  Modal.confirm({
+    title: t('pages.nodes.deleteConfirmTitle', { name: node.name }),
+    content: t('pages.nodes.deleteConfirmContent'),
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: async () => {
+      const msg = await remove(node.id);
+      if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
+    },
+  });
+}
+
+async function onProbe(node) {
+  const msg = await probe(node.id);
+  if (msg?.success && msg.obj) {
+    if (msg.obj.status === 'online') {
+      message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
+    } else {
+      message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
+    }
+  }
+}
+
+async function onToggleEnable(node, next) {
+  await setEnable(node.id, next);
+}
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout
+      class="nodes-page"
+      :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
+    >
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+              <!-- Summary statistics card -->
+              <a-col :span="24">
+                <a-card size="small" hoverable class="summary-card">
+                  <a-row :gutter="[16, 12]">
+                    <a-col :sm="12" :md="6">
+                      <CustomStatistic
+                        :title="t('pages.nodes.totalNodes')"
+                        :value="String(totals.total)"
+                      >
+                        <template #prefix>
+                          <CloudServerOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="6">
+                      <CustomStatistic
+                        :title="t('pages.nodes.onlineNodes')"
+                        :value="String(totals.online)"
+                      >
+                        <template #prefix>
+                          <CheckCircleOutlined style="color: #52c41a" />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="6">
+                      <CustomStatistic
+                        :title="t('pages.nodes.offlineNodes')"
+                        :value="String(totals.offline)"
+                      >
+                        <template #prefix>
+                          <CloseCircleOutlined style="color: #ff4d4f" />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                    <a-col :sm="12" :md="6">
+                      <CustomStatistic
+                        :title="t('pages.nodes.avgLatency')"
+                        :value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'"
+                      >
+                        <template #prefix>
+                          <ThunderboltOutlined />
+                        </template>
+                      </CustomStatistic>
+                    </a-col>
+                  </a-row>
+                </a-card>
+              </a-col>
+
+              <!-- Node table -->
+              <a-col :span="24">
+                <NodeList
+                  :nodes="nodes"
+                  :loading="loading"
+                  :is-mobile="isMobile"
+                  @add="onAdd"
+                  @edit="onEdit"
+                  @delete="onDelete"
+                  @probe="onProbe"
+                  @toggle-enable="onToggleEnable"
+                />
+              </a-col>
+            </a-row>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <NodeFormModal
+        v-model:open="formOpen"
+        :mode="formMode"
+        :node="formNode"
+        :test-connection="testConnection"
+        :save="onSave"
+      />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.nodes-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.nodes-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+
+.nodes-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.nodes-page :deep(.ant-layout),
+.nodes-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+@media (max-width: 768px) {
+  .content-area {
+    padding: 8px;
+  }
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.summary-card {
+  padding: 16px;
+}
+
+@media (max-width: 768px) {
+  .summary-card {
+    padding: 8px;
+  }
+}
+</style>

+ 120 - 0
frontend/src/pages/nodes/useNodes.js

@@ -0,0 +1,120 @@
+// Loads the node list and runs CRUD/probe actions against the
+// /panel/api/nodes/* endpoints. Live updates arrive over WebSocket
+// (pushed by NodeHeartbeatJob every 10s) so we don't poll.
+
+import { computed, onMounted, ref, shallowRef } from 'vue';
+import { HttpUtil } from '@/utils';
+
+export function useNodes() {
+  const nodes = shallowRef([]);
+  const loading = ref(false);
+  const fetched = ref(false);
+
+  async function refresh() {
+    loading.value = true;
+    try {
+      const msg = await HttpUtil.get('/panel/api/nodes/list');
+      if (msg?.success) {
+        nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
+      }
+      fetched.value = true;
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  // Replaces the local list with the snapshot pushed by the heartbeat job.
+  // shallowRef means a fresh assignment is enough to retrigger reactivity;
+  // we always assign a new array so Vue notices.
+  function applyNodesEvent(payload) {
+    if (Array.isArray(payload)) {
+      nodes.value = payload;
+      if (!fetched.value) fetched.value = true;
+    }
+  }
+
+  async function create(payload) {
+    const msg = await HttpUtil.post('/panel/api/nodes/add', payload);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function update(id, payload) {
+    const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function remove(id) {
+    const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  async function setEnable(id, enable) {
+    const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable });
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  // testConnection probes a transient (unsaved) node config so the form
+  // can validate before save. Returns the ProbeResultUI shape from Go.
+  async function testConnection(payload) {
+    const msg = await HttpUtil.post('/panel/api/nodes/test', payload);
+    return msg;
+  }
+
+  // probe forces an immediate heartbeat against an already-saved node.
+  async function probe(id) {
+    const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`);
+    if (msg?.success) await refresh();
+    return msg;
+  }
+
+  // Aggregate cards on the dashboard. Computed off the live list so a
+  // refresh (or a WS push) picks up new totals automatically.
+  const totals = computed(() => {
+    const list = nodes.value;
+    let online = 0;
+    let offline = 0;
+    let latencySum = 0;
+    let latencyCount = 0;
+    for (const n of list) {
+      if (!n.enable) continue;
+      if (n.status === 'online') {
+        online += 1;
+        if (n.latencyMs > 0) {
+          latencySum += n.latencyMs;
+          latencyCount += 1;
+        }
+      } else if (n.status === 'offline') {
+        offline += 1;
+      }
+    }
+    return {
+      total: list.length,
+      online,
+      offline,
+      avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
+    };
+  });
+
+  // Initial fetch — WebSocket takes over after the first heartbeat tick
+  // (~10s) but the page should populate immediately on mount.
+  onMounted(refresh);
+
+  return {
+    nodes,
+    loading,
+    fetched,
+    totals,
+    refresh,
+    applyNodesEvent,
+    create,
+    update,
+    remove,
+    setEnable,
+    testConnection,
+    probe,
+  };
+}

+ 425 - 0
frontend/src/pages/settings/GeneralTab.vue

@@ -0,0 +1,425 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+import { HttpUtil, LanguageManager } from '@/utils';
+import SettingListItem from '@/components/SettingListItem.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  // Reactive AllSetting instance shared with the parent page.
+  allSetting: { type: Object, required: true },
+});
+
+// Remark model — legacy stores it as a single string where index 0 is
+// the separator char and the rest is the order of model keys
+// (i=Inbound, e=Email, o=Other). Surface it as two v-models that read
+// and write the underlying string.
+const remarkModels = { i: 'Inbound', e: 'Email', o: 'Other' };
+const remarkSeparators = [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'];
+
+const remarkModel = computed({
+  get: () => {
+    const rm = props.allSetting.remarkModel || '';
+    return rm.length > 1 ? rm.substring(1).split('') : [];
+  },
+  set: (value) => {
+    const sep = (props.allSetting.remarkModel || '-').charAt(0);
+    props.allSetting.remarkModel = sep + value.join('');
+  },
+});
+
+const remarkSeparator = computed({
+  get: () => {
+    const rm = props.allSetting.remarkModel || '-';
+    return rm.length > 1 ? rm.charAt(0) : '-';
+  },
+  set: (value) => {
+    const tail = (props.allSetting.remarkModel || '-').substring(1);
+    props.allSetting.remarkModel = value + tail;
+  },
+});
+
+const remarkSample = computed(() => {
+  const parts = remarkModel.value.map((k) => remarkModels[k]);
+  return parts.length === 0 ? '' : parts.join(remarkSeparator.value);
+});
+
+const datepicker = computed({
+  get: () => props.allSetting.datepicker || 'gregorian',
+  set: (value) => { props.allSetting.datepicker = value; },
+});
+
+const datepickerList = [
+  { name: 'Gregorian (Standard)', value: 'gregorian' },
+  { name: 'Jalalian (شمسی)', value: 'jalalian' },
+];
+
+// Language is stored client-side in a cookie, NOT in AllSetting. The
+// legacy panel reloads on change so the Go side renders templates in
+// the new language.
+const lang = ref(LanguageManager.getLanguage());
+function onLangChange() {
+  LanguageManager.setLanguage(lang.value);
+}
+
+// LDAP inbound tags are CSV on the wire; expose as an array so the
+// multi-select v-model works directly.
+const ldapInboundTagList = computed({
+  get: () => {
+    const csv = props.allSetting.ldapInboundTags || '';
+    return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
+  },
+  set: (list) => {
+    props.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
+  },
+});
+
+const inboundOptions = ref([]);
+async function loadInboundTags() {
+  const msg = await HttpUtil.get('/panel/api/inbounds/list');
+  if (msg?.success && Array.isArray(msg.obj)) {
+    inboundOptions.value = msg.obj.map((ib) => ({
+      label: `${ib.tag} (${ib.protocol}@${ib.port})`,
+      value: ib.tag,
+    }));
+  } else {
+    inboundOptions.value = [];
+  }
+}
+
+onMounted(loadInboundTags);
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.remarkModel') }}</template>
+        <template #description>{{ t('pages.settings.sampleRemark') }}: <i>#{{ remarkSample }}</i></template>
+        <template #control>
+          <a-input-group :style="{ width: '100%' }">
+            <a-select v-model:value="remarkModel" mode="multiple"
+              :style="{ paddingRight: '.5rem', minWidth: '80%', width: 'auto' }">
+              <a-select-option v-for="(label, key) in remarkModels" :key="key" :value="key">
+                {{ label }}
+              </a-select-option>
+            </a-select>
+            <a-select v-model:value="remarkSeparator" :style="{ width: '20%' }">
+              <a-select-option v-for="sep in remarkSeparators" :key="sep" :value="sep">{{ sep }}</a-select-option>
+            </a-select>
+          </a-input-group>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.panelListeningIP') }}</template>
+        <template #description>{{ t('pages.settings.panelListeningIPDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.webListen" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.panelListeningDomain') }}</template>
+        <template #description>{{ t('pages.settings.panelListeningDomainDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.webDomain" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.panelPort') }}</template>
+        <template #description>{{ t('pages.settings.panelPortDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.webPort" :min="1" :max="65535" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.panelUrlPath') }}</template>
+        <template #description>{{ t('pages.settings.panelUrlPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.webBasePath" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.sessionMaxAge') }}</template>
+        <template #description>{{ t('pages.settings.sessionMaxAgeDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.sessionMaxAge" :min="60" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.pageSize') }}</template>
+        <template #description>{{ t('pages.settings.pageSizeDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.pageSize" :min="0" :step="5" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.language') }}</template>
+        <template #control>
+          <a-select v-model:value="lang" :style="{ width: '100%' }" @change="onLangChange">
+            <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
+              :label="l.value">
+              <span role="img" :aria-label="l.name">{{ l.icon }}</span>
+              &nbsp;&nbsp;<span>{{ l.name }}</span>
+            </a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.settings.notifications')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.expireTimeDiff') }}</template>
+        <template #description>{{ t('pages.settings.expireTimeDiffDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.expireDiff" :min="0" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.trafficDiff') }}</template>
+        <template #description>{{ t('pages.settings.trafficDiffDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.trafficDiff" :min="0" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" :header="t('pages.settings.certs')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.publicKeyPath') }}</template>
+        <template #description>{{ t('pages.settings.publicKeyPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.webCertFile" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.privateKeyPath') }}</template>
+        <template #description>{{ t('pages.settings.privateKeyPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.webKeyFile" type="text" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="4" :header="t('pages.settings.externalTraffic')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.externalTrafficInformEnable') }}</template>
+        <template #description>{{ t('pages.settings.externalTrafficInformEnableDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.externalTrafficInformEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.externalTrafficInformURI') }}</template>
+        <template #description>{{ t('pages.settings.externalTrafficInformURIDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.externalTrafficInformURI" placeholder="(http|https)://domain[:port]/path/"
+            type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.restartXrayOnClientDisable') }}</template>
+        <template #description>{{ t('pages.settings.restartXrayOnClientDisableDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.restartXrayOnClientDisable" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="5" :header="t('pages.settings.dateAndTime')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.timeZone') }}</template>
+        <template #description>{{ t('pages.settings.timeZoneDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.timeLocation" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.datepicker') }}</template>
+        <template #description>{{ t('pages.settings.datepickerDescription') }}</template>
+        <template #control>
+          <a-select v-model:value="datepicker" :style="{ width: '100%' }">
+            <a-select-option v-for="item in datepickerList" :key="item.value" :value="item.value">
+              {{ item.name }}
+            </a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="6" header="LDAP">
+      <SettingListItem paddings="small">
+        <template #title>Enable LDAP sync</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.ldapEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>LDAP host</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapHost" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>LDAP port</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.ldapPort" :min="1" :max="65535" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Use TLS (LDAPS)</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.ldapUseTLS" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Bind DN</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapBindDN" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('password') }}</template>
+        <template #control>
+          <a-input-password v-model:value="allSetting.ldapPassword" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Base DN</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapBaseDN" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>User filter</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapUserFilter" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>User attribute (username/email)</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapUserAttr" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>VLESS flag attribute</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapVlessField" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Generic flag attribute (optional)</template>
+        <template #description>If set, overrides VLESS flag — e.g. shadowInactive.</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapFlagField" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Truthy values</template>
+        <template #description>Comma-separated; default: true,1,yes,on</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapTruthyValues" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Invert flag</template>
+        <template #description>Enable when the attribute means disabled (e.g. shadowInactive).</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.ldapInvertFlag" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Sync schedule</template>
+        <template #description>Cron-like string, e.g. @every 1m</template>
+        <template #control>
+          <a-input v-model:value="allSetting.ldapSyncCron" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Inbound tags</template>
+        <template #description>Inbounds that LDAP sync may auto-create or auto-delete clients on.</template>
+        <template #control>
+          <a-select v-model:value="ldapInboundTagList" mode="multiple" :style="{ width: '100%' }">
+            <a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">
+              {{ opt.label }}
+            </a-select-option>
+          </a-select>
+          <div v-if="inboundOptions.length === 0" class="ldap-no-inbounds">
+            No inbounds found. Create one in Inbounds first.
+          </div>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Auto create clients</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.ldapAutoCreate" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Auto delete clients</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.ldapAutoDelete" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Default total (GB)</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.ldapDefaultTotalGB" :min="0" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Default expiry (days)</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.ldapDefaultExpiryDays" :min="0" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Default IP limit</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.ldapDefaultLimitIP" :min="0" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+  </a-collapse>
+</template>
+
+<style scoped>
+.ldap-no-inbounds {
+  margin-top: 6px;
+  color: #999;
+  font-size: 12px;
+}
+</style>

+ 245 - 0
frontend/src/pages/settings/SecurityTab.vue

@@ -0,0 +1,245 @@
+<script setup>
+import { onMounted, reactive, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+
+import { HttpUtil, RandomUtil } from '@/utils';
+import SettingListItem from '@/components/SettingListItem.vue';
+import TwoFactorModal from './TwoFactorModal.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  allSetting: { type: Object, required: true },
+});
+
+// 2FA modal state — both the "set" (enabling) and "confirm" (changing
+// password / disabling) flows route through the same component.
+const tfa = reactive({
+  open: false,
+  title: '',
+  description: '',
+  token: '',
+  type: 'set',
+  // resolveConfirm is called by the modal's @confirm with the success bool;
+  // it then routes the value back to whichever flow opened the modal.
+  resolveConfirm: (_success) => { },
+});
+
+function openTfa({ title, description = '', token = '', type, onConfirm }) {
+  tfa.title = title;
+  tfa.description = description;
+  tfa.token = token;
+  tfa.type = type;
+  tfa.resolveConfirm = onConfirm;
+  tfa.open = true;
+}
+
+function onTfaConfirm(success) {
+  tfa.resolveConfirm(success);
+}
+
+const user = reactive({
+  oldUsername: '',
+  oldPassword: '',
+  newUsername: '',
+  newPassword: '',
+});
+const updating = ref(false);
+
+async function sendUpdateUser() {
+  updating.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/setting/updateUser', user);
+    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__ || '';
+      window.location.replace(`${basePath}logout`);
+    }
+  } finally {
+    updating.value = false;
+  }
+}
+
+function updateUser() {
+  if (props.allSetting.twoFactorEnable) {
+    openTfa({
+      title: t('pages.settings.security.twoFactorModalChangeCredentialsTitle'),
+      description: t('pages.settings.security.twoFactorModalChangeCredentialsStep'),
+      token: props.allSetting.twoFactorToken,
+      type: 'confirm',
+      onConfirm: (ok) => { if (ok) sendUpdateUser(); },
+    });
+  } else {
+    sendUpdateUser();
+  }
+}
+
+// === API Token =========================================================
+// Surfaces the panel's API token so a remote central panel can register
+// this instance as a node. Lazy-loaded on tab mount; rotation requires
+// confirmation since it invalidates any cached value upstream.
+const apiToken = ref('');
+const apiTokenLoading = ref(false);
+const apiTokenRotating = ref(false);
+
+async function loadApiToken() {
+  apiTokenLoading.value = true;
+  try {
+    const msg = await HttpUtil.get('/panel/setting/getApiToken');
+    if (msg?.success) apiToken.value = msg.obj || '';
+  } finally {
+    apiTokenLoading.value = false;
+  }
+}
+
+async function copyApiToken() {
+  if (!apiToken.value) return;
+  try {
+    await navigator.clipboard.writeText(apiToken.value);
+    message.success(t('copySuccess'));
+  } catch (_e) {
+    // navigator.clipboard can be undefined on http:// — fall back to
+    // a transient input + execCommand path.
+    const ta = document.createElement('textarea');
+    ta.value = apiToken.value;
+    document.body.appendChild(ta);
+    ta.select();
+    document.execCommand('copy');
+    document.body.removeChild(ta);
+    message.success(t('copySuccess'));
+  }
+}
+
+function regenerateApiToken() {
+  Modal.confirm({
+    title: t('pages.nodes.regenerateConfirm'),
+    okText: t('confirm'),
+    cancelText: t('cancel'),
+    okType: 'danger',
+    onOk: async () => {
+      apiTokenRotating.value = true;
+      try {
+        const msg = await HttpUtil.post('/panel/setting/regenerateApiToken');
+        if (msg?.success) {
+          apiToken.value = msg.obj || '';
+          message.success(t('success'));
+        }
+      } finally {
+        apiTokenRotating.value = false;
+      }
+    },
+  });
+}
+
+onMounted(loadApiToken);
+
+function toggleTwoFactor() {
+  // Switch read-only — the actual flip happens after the modal succeeds.
+  if (!props.allSetting.twoFactorEnable) {
+    const newToken = RandomUtil.randomBase32String();
+    openTfa({
+      title: t('pages.settings.security.twoFactorModalSetTitle'),
+      token: newToken,
+      type: 'set',
+      onConfirm: (ok) => {
+        if (ok) {
+          message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
+          props.allSetting.twoFactorToken = newToken;
+        }
+        props.allSetting.twoFactorEnable = ok;
+      },
+    });
+  } else {
+    openTfa({
+      title: t('pages.settings.security.twoFactorModalDeleteTitle'),
+      description: t('pages.settings.security.twoFactorModalRemoveStep'),
+      token: props.allSetting.twoFactorToken,
+      type: 'confirm',
+      onConfirm: (ok) => {
+        if (!ok) return;
+        message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
+        props.allSetting.twoFactorEnable = false;
+        props.allSetting.twoFactorToken = '';
+      },
+    });
+  }
+}
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.settings.security.admin')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.oldUsername') }}</template>
+        <template #control>
+          <a-input v-model:value="user.oldUsername" autocomplete="username" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.currentPassword') }}</template>
+        <template #control>
+          <a-input-password v-model:value="user.oldPassword" autocomplete="current-password" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.newUsername') }}</template>
+        <template #control>
+          <a-input v-model:value="user.newUsername" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.newPassword') }}</template>
+        <template #control>
+          <a-input-password v-model:value="user.newPassword" autocomplete="new-password" />
+        </template>
+      </SettingListItem>
+
+      <a-list-item>
+        <a-space direction="horizontal" :style="{ padding: '0 20px' }">
+          <a-button type="primary" :loading="updating" @click="updateUser">{{ t('confirm') }}</a-button>
+        </a-space>
+      </a-list-item>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.settings.security.twoFactor')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.security.twoFactorEnable') }}</template>
+        <template #description>{{ t('pages.settings.security.twoFactorEnableDesc') }}</template>
+        <template #control>
+          <a-switch :checked="allSetting.twoFactorEnable" @click="toggleTwoFactor" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" :header="t('pages.nodes.apiToken')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.nodes.apiToken') }}</template>
+        <template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
+        <template #control>
+          <a-input-password
+            :value="apiToken"
+            readonly
+            :loading="apiTokenLoading"
+            style="min-width: 240px"
+          />
+        </template>
+      </SettingListItem>
+      <a-list-item>
+        <a-space direction="horizontal" :style="{ padding: '0 20px' }">
+          <a-button :disabled="!apiToken" @click="copyApiToken">{{ t('copy') }}</a-button>
+          <a-button danger :loading="apiTokenRotating" @click="regenerateApiToken">
+            {{ t('pages.nodes.regenerate') }}
+          </a-button>
+        </a-space>
+      </a-list-item>
+    </a-collapse-panel>
+  </a-collapse>
+
+  <TwoFactorModal v-model:open="tfa.open" :title="tfa.title" :description="tfa.description" :token="tfa.token"
+    :type="tfa.type" @confirm="onTfaConfirm" />
+</template>

+ 309 - 0
frontend/src/pages/settings/SettingsPage.vue

@@ -0,0 +1,309 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal } from 'ant-design-vue';
+import {
+  SettingOutlined,
+  SafetyOutlined,
+  MessageOutlined,
+  CloudServerOutlined,
+  CodeOutlined,
+} from '@ant-design/icons-vue';
+
+import { HttpUtil, PromiseUtil } from '@/utils';
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import { useAllSetting } from './useAllSetting.js';
+import GeneralTab from './GeneralTab.vue';
+import SecurityTab from './SecurityTab.vue';
+import TelegramTab from './TelegramTab.vue';
+import SubscriptionGeneralTab from './SubscriptionGeneralTab.vue';
+import SubscriptionFormatsTab from './SubscriptionFormatsTab.vue';
+
+const { t } = useI18n();
+
+const { fetched, spinning, saveDisabled, allSetting, saveAll } = useAllSetting();
+const { isMobile } = useMediaQuery();
+
+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
+// scrolled element. Inline-arrow `() => document.getElementById(...)`
+// in the template threw "Cannot read properties of undefined (reading
+// 'getElementById')" because of how Vue 3 evaluates the expression
+// outside the script-setup scope — wrap in a regular function so
+// `document` resolves to the window global at call time.
+function scrollTarget() {
+  return document.getElementById('content-layout');
+}
+
+// `entry*` mirrors the URL the user opened the panel with so the page
+// can rebuild it after a restart that may change host/port/scheme.
+const entryHost = ref('');
+const entryPort = ref('');
+const entryIsIP = ref(false);
+
+function isIp(h) {
+  if (typeof h !== 'string') return false;
+  // IPv4: four dot-separated octets 0-255.
+  const v4 = h.split('.');
+  if (v4.length === 4 && v4.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)) return true;
+  // IPv6: hex groups, optional single :: compression.
+  if (!h.includes(':') || h.includes(':::')) return false;
+  const parts = h.split('::');
+  if (parts.length > 2) return false;
+  const split = (s) => (s ? s.split(':').filter(Boolean) : []);
+  const head = split(parts[0]);
+  const tail = split(parts[1]);
+  const valid = (seg) => /^[0-9a-fA-F]{1,4}$/.test(seg);
+  if (![...head, ...tail].every(valid)) return false;
+  const groups = head.length + tail.length;
+  return parts.length === 2 ? groups < 8 : groups === 8;
+}
+
+onMounted(() => {
+  entryHost.value = window.location.hostname;
+  entryPort.value = window.location.port;
+  entryIsIP.value = isIp(entryHost.value);
+});
+
+// Rebuild the URL after a restart — host/port/scheme may have changed
+// (cert toggled on, port edited, base path edited).
+function rebuildUrlAfterRestart() {
+  const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
+  const newProtocol = (webCertFile || webKeyFile) ? 'https:' : 'http:';
+
+  let base = webBasePath ? webBasePath.replace(/^\//, '') : '';
+  if (base && !base.endsWith('/')) base += '/';
+
+  if (!entryIsIP.value) {
+    const url = new URL(window.location.href);
+    url.pathname = `/${base}panel/settings`;
+    url.protocol = newProtocol;
+    return url.toString();
+  }
+
+  let finalHost = entryHost.value;
+  let finalPort = entryPort.value || '';
+  if (webDomain && isIp(webDomain)) finalHost = webDomain;
+  if (webPort && Number(webPort) !== Number(entryPort.value)) finalPort = String(webPort);
+
+  const url = new URL(`${newProtocol}//${finalHost}`);
+  if (finalPort) url.port = finalPort;
+  url.pathname = `/${base}panel/settings`;
+  return url.toString();
+}
+
+async function restartPanel() {
+  await new Promise((resolve, reject) => {
+    Modal.confirm({
+      title: 'Restart panel',
+      content: 'Restart the panel now? Your session will reconnect once it comes back.',
+      okText: 'Restart',
+      cancelText: 'Cancel',
+      onOk: () => resolve(),
+      onCancel: () => reject(new Error('cancelled')),
+    });
+  }).catch(() => null);
+
+  spinning.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/setting/restartPanel');
+    if (!msg?.success) return;
+    await PromiseUtil.sleep(5000);
+    window.location.replace(rebuildUrlAfterRestart());
+  } finally {
+    spinning.value = false;
+  }
+}
+
+// Conf alerts mirror the legacy banner — pure derivation off allSetting.
+const confAlerts = computed(() => {
+  const out = [];
+  if (window.location.protocol !== 'https:') {
+    out.push('Panel is served over plain HTTP — set up TLS for production.');
+  }
+  if (allSetting.webPort === 2053) {
+    out.push('Default port 2053 is well-known — change it to a random port.');
+  }
+  const segs = window.location.pathname.split('/').length < 4;
+  if (segs && allSetting.webBasePath === '/') {
+    out.push('Default base path "/" is well-known — change it to a random path.');
+  }
+  if (allSetting.subEnable) {
+    let subPath = allSetting.subPath;
+    if (allSetting.subURI) {
+      try { subPath = new URL(allSetting.subURI).pathname; } catch (_e) { }
+    }
+    if (subPath === '/sub/') {
+      out.push('Default subscription path "/sub/" is well-known — change it.');
+    }
+  }
+  if (allSetting.subJsonEnable) {
+    let p = allSetting.subJsonPath;
+    if (allSetting.subJsonURI) {
+      try { p = new URL(allSetting.subJsonURI).pathname; } catch (_e) { }
+    }
+    if (p === '/json/') {
+      out.push('Default JSON subscription path "/json/" is well-known — change it.');
+    }
+  }
+  return out;
+});
+
+const alertVisible = ref(true);
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="settings-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <template v-else>
+              <a-alert v-if="confAlerts.length > 0 && alertVisible" type="error" show-icon closable class="conf-alert"
+                @close="alertVisible = false">
+                <template #message>Security warnings</template>
+                <template #description>
+                  <b>Your panel may be exposed:</b>
+                  <ul>
+                    <li v-for="(msg, i) in confAlerts" :key="i">{{ msg }}</li>
+                  </ul>
+                </template>
+              </a-alert>
+
+              <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+                <a-col :span="24">
+                  <a-card hoverable>
+                    <a-row class="header-row">
+                      <a-col :xs="24" :sm="10" class="header-actions">
+                        <a-space direction="horizontal">
+                          <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
+                            {{ t('pages.settings.save') }}
+                          </a-button>
+                          <a-button type="primary" danger :disabled="!saveDisabled" @click="restartPanel">
+                            {{ t('pages.settings.restartPanel') }}
+                          </a-button>
+                        </a-space>
+                      </a-col>
+                      <a-col :xs="24" :sm="14" class="header-info">
+                        <a-back-top :target="scrollTarget" :visibility-height="200" />
+                        <a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
+                      </a-col>
+                    </a-row>
+                  </a-card>
+                </a-col>
+
+                <a-col :span="24">
+                  <a-tabs default-active-key="1">
+                    <a-tab-pane key="1" class="tab-pane">
+                      <template #tab>
+                        <SettingOutlined />
+                        <span>{{ t('pages.settings.panelSettings') }}</span>
+                      </template>
+                      <GeneralTab :all-setting="allSetting" />
+                    </a-tab-pane>
+                    <a-tab-pane key="2" class="tab-pane">
+                      <template #tab>
+                        <SafetyOutlined />
+                        <span>{{ t('pages.settings.securitySettings') }}</span>
+                      </template>
+                      <SecurityTab :all-setting="allSetting" />
+                    </a-tab-pane>
+                    <a-tab-pane key="3" class="tab-pane">
+                      <template #tab>
+                        <MessageOutlined />
+                        <span>{{ t('pages.settings.TGBotSettings') }}</span>
+                      </template>
+                      <TelegramTab :all-setting="allSetting" />
+                    </a-tab-pane>
+                    <a-tab-pane key="4" class="tab-pane">
+                      <template #tab>
+                        <CloudServerOutlined />
+                        <span>{{ t('pages.settings.subSettings') }}</span>
+                      </template>
+                      <SubscriptionGeneralTab :all-setting="allSetting" />
+                    </a-tab-pane>
+                    <a-tab-pane v-if="allSetting.subJsonEnable || allSetting.subClashEnable" key="5" class="tab-pane">
+                      <template #tab>
+                        <CodeOutlined />
+                        <span>{{ t('pages.settings.subSettings') }} (Formats)</span>
+                      </template>
+                      <SubscriptionFormatsTab :all-setting="allSetting" />
+                    </a-tab-pane>
+                  </a-tabs>
+                </a-col>
+              </a-row>
+            </template>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.settings-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.settings-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+
+.settings-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.settings-page :deep(.ant-layout),
+.settings-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell {
+  background: transparent;
+}
+
+.content-area {
+  padding: 24px;
+}
+
+.loading-spacer {
+  min-height: calc(100vh - 120px);
+}
+
+.conf-alert {
+  margin-bottom: 10px;
+}
+
+.header-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+}
+
+.header-actions {
+  padding: 4px;
+}
+
+.header-info {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.tab-pane {
+  padding-top: 20px;
+}
+</style>

+ 433 - 0
frontend/src/pages/settings/SubscriptionFormatsTab.vue

@@ -0,0 +1,433 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import SettingListItem from '@/components/SettingListItem.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  allSetting: { type: Object, required: true },
+});
+
+// === Defaults (match legacy) ============================================
+const DEFAULT_FRAGMENT = {
+  packets: 'tlshello',
+  length: '100-200',
+  interval: '10-20',
+  maxSplit: '300-400',
+};
+const DEFAULT_NOISES = [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }];
+const DEFAULT_MUX = {
+  enabled: true,
+  concurrency: 8,
+  xudpConcurrency: 16,
+  xudpProxyUDP443: 'reject',
+};
+const DEFAULT_RULES = [
+  { type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
+  { type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
+];
+
+const directIPsOptions = [
+  { label: 'Private IP', value: 'geoip:private' },
+  { label: '🇮🇷 Iran', value: 'geoip:ir' },
+  { label: '🇨🇳 China', value: 'geoip:cn' },
+  { label: '🇷🇺 Russia', value: 'geoip:ru' },
+  { label: '🇻🇳 Vietnam', value: 'geoip:vn' },
+  { label: '🇪🇸 Spain', value: 'geoip:es' },
+  { label: '🇮🇩 Indonesia', value: 'geoip:id' },
+  { label: '🇺🇦 Ukraine', value: 'geoip:ua' },
+  { label: '🇹🇷 Türkiye', value: 'geoip:tr' },
+  { label: '🇧🇷 Brazil', value: 'geoip:br' },
+];
+const directDomainsOptions = [
+  { label: 'Private DNS', value: 'geosite:private' },
+  { label: '🇮🇷 Iran', value: 'geosite:category-ir' },
+  { label: '🇨🇳 China', value: 'geosite:cn' },
+  { label: '🇷🇺 Russia', value: 'geosite:category-ru' },
+  { label: 'Apple', value: 'geosite:apple' },
+  { label: 'Meta', value: 'geosite:meta' },
+  { label: 'Google', value: 'geosite:google' },
+];
+
+// === Path helpers (json + clash share the same shape) ===================
+function makePath(field) {
+  return computed({
+    get: () => props.allSetting[field],
+    set: (v) => {
+      props.allSetting[field] = String(v ?? '').replace(/[:*]/g, '');
+    },
+  });
+}
+function normalizePath(field) {
+  let p = props.allSetting[field] || '/';
+  if (!p.startsWith('/')) p = '/' + p;
+  if (!p.endsWith('/')) p += '/';
+  p = p.replace(/\/+/g, '/');
+  props.allSetting[field] = p;
+}
+const subJsonPath = makePath('subJsonPath');
+const subClashPath = makePath('subClashPath');
+
+// === Fragment ===========================================================
+// `subJsonFragment` is a JSON-encoded object when enabled, "" when off.
+function readJson(field, fallback) {
+  try {
+    const raw = props.allSetting[field];
+    if (!raw) return fallback;
+    return JSON.parse(raw);
+  } catch (_e) {
+    return fallback;
+  }
+}
+function writeJson(field, value) {
+  props.allSetting[field] = JSON.stringify(value);
+}
+
+const fragment = computed({
+  get: () => props.allSetting.subJsonFragment !== '',
+  set: (v) => {
+    props.allSetting.subJsonFragment = v ? JSON.stringify(DEFAULT_FRAGMENT) : '';
+  },
+});
+function makeFragmentField(key) {
+  return computed({
+    get: () => (fragment.value ? readJson('subJsonFragment', DEFAULT_FRAGMENT)[key] : ''),
+    set: (v) => {
+      if (v === '') return;
+      const f = readJson('subJsonFragment', { ...DEFAULT_FRAGMENT });
+      f[key] = v;
+      writeJson('subJsonFragment', f);
+    },
+  });
+}
+const fragmentPackets = makeFragmentField('packets');
+const fragmentLength = makeFragmentField('length');
+const fragmentInterval = makeFragmentField('interval');
+const fragmentMaxSplit = makeFragmentField('maxSplit');
+
+// === Noises =============================================================
+const noises = computed({
+  get: () => props.allSetting.subJsonNoises !== '',
+  set: (v) => {
+    props.allSetting.subJsonNoises = v ? JSON.stringify(DEFAULT_NOISES) : '';
+  },
+});
+const noisesArray = computed({
+  get: () => (noises.value ? readJson('subJsonNoises', DEFAULT_NOISES) : []),
+  set: (value) => { if (noises.value) writeJson('subJsonNoises', value); },
+});
+function addNoise() {
+  noisesArray.value = [...noisesArray.value, { ...DEFAULT_NOISES[0] }];
+}
+function removeNoise(index) {
+  const next = [...noisesArray.value];
+  next.splice(index, 1);
+  noisesArray.value = next;
+}
+function updateNoiseField(index, field, value) {
+  const next = [...noisesArray.value];
+  next[index] = { ...next[index], [field]: value };
+  noisesArray.value = next;
+}
+
+// === Mux ================================================================
+const enableMux = computed({
+  get: () => props.allSetting.subJsonMux !== '',
+  set: (v) => {
+    props.allSetting.subJsonMux = v ? JSON.stringify(DEFAULT_MUX) : '';
+  },
+});
+function makeMuxField(key, fallback) {
+  return computed({
+    get: () => (enableMux.value ? readJson('subJsonMux', DEFAULT_MUX)[key] : fallback),
+    set: (v) => {
+      const m = readJson('subJsonMux', { ...DEFAULT_MUX });
+      m[key] = v;
+      writeJson('subJsonMux', m);
+    },
+  });
+}
+const muxConcurrency = makeMuxField('concurrency', -1);
+const muxXudpConcurrency = makeMuxField('xudpConcurrency', -1);
+const muxXudpProxyUDP443 = makeMuxField('xudpProxyUDP443', 'reject');
+
+// === Direct routing rules ==============================================
+// `subJsonRules` is a JSON array of xray routing rules. We surface the
+// IP and domain fields of the two seed rules as multi-select tags.
+const enableDirect = computed({
+  get: () => props.allSetting.subJsonRules !== '',
+  set: (v) => {
+    props.allSetting.subJsonRules = v ? JSON.stringify(DEFAULT_RULES) : '';
+  },
+});
+function ruleArray() {
+  if (!enableDirect.value) return null;
+  const rules = readJson('subJsonRules', null);
+  return Array.isArray(rules) ? rules : null;
+}
+const directIPs = computed({
+  get: () => {
+    const rules = ruleArray();
+    if (!rules) return [];
+    const ipRule = rules.find((r) => r.ip);
+    return ipRule?.ip ?? [];
+  },
+  set: (value) => {
+    let rules = ruleArray();
+    if (!rules) return;
+    if (value.length === 0) {
+      rules = rules.filter((r) => !r.ip);
+    } else {
+      let idx = rules.findIndex((r) => r.ip);
+      if (idx === -1) idx = rules.push({ ...DEFAULT_RULES[1] }) - 1;
+      rules[idx].ip = [...value];
+    }
+    writeJson('subJsonRules', rules);
+  },
+});
+const directDomains = computed({
+  get: () => {
+    const rules = ruleArray();
+    if (!rules) return [];
+    const dRule = rules.find((r) => r.domain);
+    return dRule?.domain ?? [];
+  },
+  set: (value) => {
+    let rules = ruleArray();
+    if (!rules) return;
+    if (value.length === 0) {
+      rules = rules.filter((r) => !r.domain);
+    } else {
+      let idx = rules.findIndex((r) => r.domain);
+      if (idx === -1) idx = rules.push({ ...DEFAULT_RULES[0] }) - 1;
+      rules[idx].domain = [...value];
+    }
+    writeJson('subJsonRules', rules);
+  },
+});
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
+      <SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
+        <template #title>JSON {{ t('pages.settings.subPath') }}</template>
+        <template #description>{{ t('pages.settings.subPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="subJsonPath" type="text" placeholder="/json/" @blur="normalizePath('subJsonPath')" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem v-if="allSetting.subJsonEnable" paddings="small">
+        <template #title>JSON {{ t('pages.settings.subURI') }}</template>
+        <template #description>{{ t('pages.settings.subURIDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subJsonURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem v-if="allSetting.subClashEnable" paddings="small">
+        <template #title>Clash {{ t('pages.settings.subPath') }}</template>
+        <template #description>{{ t('pages.settings.subPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="subClashPath" type="text" placeholder="/clash/"
+            @blur="normalizePath('subClashPath')" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem v-if="allSetting.subClashEnable" paddings="small">
+        <template #title>Clash {{ t('pages.settings.subURI') }}</template>
+        <template #description>{{ t('pages.settings.subURIDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subClashURI" type="text"
+            placeholder="(http|https)://domain[:port]/path/" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.settings.fragment')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.fragment') }}</template>
+        <template #description>{{ t('pages.settings.fragmentDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="fragment" />
+        </template>
+      </SettingListItem>
+
+      <a-list-item v-if="fragment" class="nested-block">
+        <a-collapse>
+          <a-collapse-panel :header="t('pages.settings.fragmentSett')">
+            <SettingListItem paddings="small">
+              <template #title>Packets</template>
+              <template #control>
+                <a-input v-model:value="fragmentPackets" placeholder="1-1 | 1-3 | tlshello | …" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Length</template>
+              <template #control>
+                <a-input v-model:value="fragmentLength" placeholder="100-200" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Interval</template>
+              <template #control>
+                <a-input v-model:value="fragmentInterval" placeholder="10-20" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Max split</template>
+              <template #control>
+                <a-input v-model:value="fragmentMaxSplit" placeholder="300-400" />
+              </template>
+            </SettingListItem>
+          </a-collapse-panel>
+        </a-collapse>
+      </a-list-item>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" header="Noises">
+      <SettingListItem paddings="small">
+        <template #title>Noises</template>
+        <template #description>{{ t('pages.settings.noisesDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="noises" />
+        </template>
+      </SettingListItem>
+
+      <a-list-item v-if="noises" class="nested-block">
+        <a-collapse>
+          <a-collapse-panel v-for="(noise, index) in noisesArray" :key="index" :header="`Noise №${index + 1}`">
+            <SettingListItem paddings="small">
+              <template #title>Type</template>
+              <template #control>
+                <a-select :value="noise.type" :style="{ width: '100%' }"
+                  @change="(v) => updateNoiseField(index, 'type', v)">
+                  <a-select-option v-for="p in ['rand', 'base64', 'str', 'hex']" :key="p" :value="p">
+                    {{ p }}
+                  </a-select-option>
+                </a-select>
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Packet</template>
+              <template #control>
+                <a-input :value="noise.packet" placeholder="5-10"
+                  @input="(e) => updateNoiseField(index, 'packet', e.target.value)" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Delay (ms)</template>
+              <template #control>
+                <a-input :value="noise.delay" placeholder="10-20"
+                  @input="(e) => updateNoiseField(index, 'delay', e.target.value)" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>Apply to</template>
+              <template #control>
+                <a-select :value="noise.applyTo" :style="{ width: '100%' }"
+                  @change="(v) => updateNoiseField(index, 'applyTo', v)">
+                  <a-select-option v-for="p in ['ip', 'ipv4', 'ipv6']" :key="p" :value="p">
+                    {{ p }}
+                  </a-select-option>
+                </a-select>
+              </template>
+            </SettingListItem>
+
+            <a-space direction="horizontal" :style="{ padding: '10px 20px' }">
+              <a-button v-if="noisesArray.length > 1" type="primary" danger @click="removeNoise(index)">
+                {{ t('delete') }}
+              </a-button>
+            </a-space>
+          </a-collapse-panel>
+        </a-collapse>
+
+        <a-button type="primary" :style="{ marginTop: '10px' }" @click="addNoise">+ Noise</a-button>
+      </a-list-item>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="4" :header="t('pages.settings.mux')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.mux') }}</template>
+        <template #description>{{ t('pages.settings.muxDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="enableMux" />
+        </template>
+      </SettingListItem>
+
+      <a-list-item v-if="enableMux" class="nested-block">
+        <a-collapse>
+          <a-collapse-panel :header="t('pages.settings.muxSett')">
+            <SettingListItem paddings="small">
+              <template #title>Concurrency</template>
+              <template #control>
+                <a-input-number v-model:value="muxConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>xudp concurrency</template>
+              <template #control>
+                <a-input-number v-model:value="muxXudpConcurrency" :min="-1" :max="1024" :style="{ width: '100%' }" />
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>xudp UDP 443</template>
+              <template #control>
+                <a-select v-model:value="muxXudpProxyUDP443" :style="{ width: '100%' }">
+                  <a-select-option v-for="p in ['reject', 'allow', 'skip']" :key="p" :value="p">
+                    {{ p }}
+                  </a-select-option>
+                </a-select>
+              </template>
+            </SettingListItem>
+          </a-collapse-panel>
+        </a-collapse>
+      </a-list-item>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="5" :header="t('pages.settings.direct')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.direct') }}</template>
+        <template #description>{{ t('pages.settings.directDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="enableDirect" />
+        </template>
+      </SettingListItem>
+
+      <a-list-item v-if="enableDirect" class="nested-block">
+        <a-collapse>
+          <a-collapse-panel :header="t('pages.settings.direct')">
+            <SettingListItem paddings="small">
+              <template #title>{{ t('pages.settings.direct') }} IPs</template>
+              <template #control>
+                <a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
+                  <a-select-option v-for="p in directIPsOptions" :key="p.value" :value="p.value" :label="p.label">
+                    {{ p.label }}
+                  </a-select-option>
+                </a-select>
+              </template>
+            </SettingListItem>
+            <SettingListItem paddings="small">
+              <template #title>{{ t('pages.settings.direct') }} {{ t('domainName') }}</template>
+              <template #control>
+                <a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
+                  <a-select-option v-for="p in directDomainsOptions" :key="p.value" :value="p.value" :label="p.label">
+                    {{ p.label }}
+                  </a-select-option>
+                </a-select>
+              </template>
+            </SettingListItem>
+          </a-collapse-panel>
+        </a-collapse>
+      </a-list-item>
+    </a-collapse-panel>
+  </a-collapse>
+</template>
+
+<style scoped>
+.nested-block {
+  padding: 10px 20px;
+}
+</style>

+ 196 - 0
frontend/src/pages/settings/SubscriptionGeneralTab.vue

@@ -0,0 +1,196 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import SettingListItem from '@/components/SettingListItem.vue';
+
+const { t } = useI18n();
+
+const props = defineProps({
+  allSetting: { type: Object, required: true },
+});
+
+// Sub path is constrained: no `:` or `*`, must start and end with `/`,
+// and no double slashes. Strip on input, normalize on blur — same
+// behavior as the legacy template.
+const subPath = computed({
+  get: () => props.allSetting.subPath,
+  set: (v) => {
+    props.allSetting.subPath = String(v ?? '').replace(/[:*]/g, '');
+  },
+});
+
+function normalizeSubPath() {
+  let p = props.allSetting.subPath || '/';
+  if (!p.startsWith('/')) p = '/' + p;
+  if (!p.endsWith('/')) p += '/';
+  p = p.replace(/\/+/g, '/');
+  props.allSetting.subPath = p;
+}
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subEnable') }}</template>
+        <template #description>{{ t('pages.settings.subEnableDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>JSON subscription</template>
+        <template #description>{{ t('pages.settings.subJsonEnable') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subJsonEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>Clash / Mihomo subscription</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subClashEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subListen') }}</template>
+        <template #description>{{ t('pages.settings.subListenDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subListen" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subDomain') }}</template>
+        <template #description>{{ t('pages.settings.subDomainDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subDomain" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subPort') }}</template>
+        <template #description>{{ t('pages.settings.subPortDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.subPort" :min="1" :max="65535" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subPath') }}</template>
+        <template #description>{{ t('pages.settings.subPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="subPath" type="text" placeholder="/sub/" @blur="normalizeSubPath" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subURI') }}</template>
+        <template #description>{{ t('pages.settings.subURIDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subURI" type="text" placeholder="(http|https)://domain[:port]/path/" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.settings.information')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subEncrypt') }}</template>
+        <template #description>{{ t('pages.settings.subEncryptDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subEncrypt" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subShowInfo') }}</template>
+        <template #description>{{ t('pages.settings.subShowInfoDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subShowInfo" />
+        </template>
+      </SettingListItem>
+
+      <a-divider>{{ t('pages.settings.subTitle') }}</a-divider>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subTitle') }}</template>
+        <template #description>{{ t('pages.settings.subTitleDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subTitle" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subSupportUrl') }}</template>
+        <template #description>{{ t('pages.settings.subSupportUrlDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subSupportUrl" type="text" placeholder="https://example.com" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subProfileUrl') }}</template>
+        <template #description>{{ t('pages.settings.subProfileUrlDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subProfileUrl" type="text" placeholder="https://example.com" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subAnnounce') }}</template>
+        <template #description>{{ t('pages.settings.subAnnounceDesc') }}</template>
+        <template #control>
+          <a-textarea v-model:value="allSetting.subAnnounce" />
+        </template>
+      </SettingListItem>
+
+      <a-divider>Happ</a-divider>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subEnableRouting') }}</template>
+        <template #description>{{ t('pages.settings.subEnableRoutingDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.subEnableRouting" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subRoutingRules') }}</template>
+        <template #description>{{ t('pages.settings.subRoutingRulesDesc') }}</template>
+        <template #control>
+          <a-textarea v-model:value="allSetting.subRoutingRules" placeholder="happ://routing/add/..." />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" :header="t('pages.settings.certs')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subCertPath') }}</template>
+        <template #description>{{ t('pages.settings.subCertPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subCertFile" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subKeyPath') }}</template>
+        <template #description>{{ t('pages.settings.subKeyPathDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.subKeyFile" type="text" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="4" :header="t('pages.settings.intervals')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.subUpdates') }}</template>
+        <template #description>{{ t('pages.settings.subUpdatesDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.subUpdates" :min="1" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+  </a-collapse>
+</template>

+ 106 - 0
frontend/src/pages/settings/TelegramTab.vue

@@ -0,0 +1,106 @@
+<script setup>
+import { useI18n } from 'vue-i18n';
+import { LanguageManager } from '@/utils';
+import SettingListItem from '@/components/SettingListItem.vue';
+
+const { t } = useI18n();
+
+defineProps({
+  allSetting: { type: Object, required: true },
+});
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.settings.panelSettings')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramBotEnable') }}</template>
+        <template #description>{{ t('pages.settings.telegramBotEnableDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.tgBotEnable" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramToken') }}</template>
+        <template #description>{{ t('pages.settings.telegramTokenDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.tgBotToken" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramChatId') }}</template>
+        <template #description>{{ t('pages.settings.telegramChatIdDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.tgBotChatId" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramBotLanguage') }}</template>
+        <template #control>
+          <a-select v-model:value="allSetting.tgLang" :style="{ width: '100%' }">
+            <a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value" :value="l.value"
+              :label="l.value">
+              <span role="img" :aria-label="l.name">{{ l.icon }}</span>
+              &nbsp;&nbsp;<span>{{ l.name }}</span>
+            </a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.settings.notifications')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramNotifyTime') }}</template>
+        <template #description>{{ t('pages.settings.telegramNotifyTimeDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.tgRunTime" type="text" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.tgNotifyBackup') }}</template>
+        <template #description>{{ t('pages.settings.tgNotifyBackupDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.tgBotBackup" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.tgNotifyLogin') }}</template>
+        <template #description>{{ t('pages.settings.tgNotifyLoginDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="allSetting.tgBotLoginNotify" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.tgNotifyCpu') }}</template>
+        <template #description>{{ t('pages.settings.tgNotifyCpuDesc') }}</template>
+        <template #control>
+          <a-input-number v-model:value="allSetting.tgCpu" :min="0" :max="100" :style="{ width: '100%' }" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" :header="t('pages.settings.proxyAndServer')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramProxy') }}</template>
+        <template #description>{{ t('pages.settings.telegramProxyDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.tgBotProxy" type="text" placeholder="socks5://user:pass@host:port" />
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.settings.telegramAPIServer') }}</template>
+        <template #description>{{ t('pages.settings.telegramAPIServerDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="allSetting.tgBotAPIServer" type="text" placeholder="https://api.example.com" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+  </a-collapse>
+</template>

+ 181 - 0
frontend/src/pages/settings/TwoFactorModal.vue

@@ -0,0 +1,181 @@
+<script setup>
+import { nextTick, 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: '' },
+  description: { type: String, default: '' },
+  token: { type: String, default: '' },
+  type: { type: String, default: 'set', validator: (v) => ['set', 'confirm'].includes(v) },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const enteredCode = ref('');
+const qrCanvas = ref(null);
+
+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',
+    label: 'Administrator',
+    algorithm: 'SHA1',
+    digits: 6,
+    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',
+  });
+}
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  enteredCode.value = '';
+  if (props.token) {
+    buildTotp();
+    if (props.type === 'set') paintQr();
+  }
+});
+
+function close(success) {
+  emit('confirm', success);
+  emit('update:open', false);
+  enteredCode.value = '';
+}
+
+function onOk() {
+  if (!totp) return;
+  if (totp.generate() === enteredCode.value) {
+    close(true);
+  } else {
+    message.error(t('pages.settings.security.twoFactorModalError'));
+  }
+}
+
+function onCancel() {
+  close(false);
+}
+
+async function copyToken() {
+  const ok = await ClipboardManager.copyText(props.token);
+  if (ok) message.success(t('copied'));
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :closable="true" @cancel="onCancel">
+    <template v-if="type === 'set'">
+      <p>{{ t('pages.settings.security.twoFactorModalSteps') }}</p>
+      <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>
+        <span class="qr-token">{{ token }}</span>
+      </div>
+      <a-divider />
+      <p>{{ t('pages.settings.security.twoFactorModalSecondStep') }}</p>
+      <a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
+    </template>
+
+    <template v-else>
+      <p>{{ description }}</p>
+      <a-input v-model:value="enteredCode" :style="{ width: '100%' }" />
+    </template>
+
+    <template #footer>
+      <a-button @click="onCancel">{{ t('cancel') }}</a-button>
+      <a-button type="primary" :disabled="enteredCode.length < 6" @click="onOk">{{ t('confirm') }}</a-button>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.qr-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+}
+
+.qr-bg {
+  width: 180px;
+  height: 180px;
+  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;
+  word-break: break-all;
+  text-align: center;
+}
+</style>

+ 80 - 0
frontend/src/pages/settings/useAllSetting.js

@@ -0,0 +1,80 @@
+// Centralizes the AllSetting fetch/save lifecycle the legacy panel
+// scattered across data() + methods + a busy-loop dirty checker.
+//
+// The dirty flag is recomputed once per second (matching the legacy
+// `while (true) sleep(1000)` poll) — we don't deep-watch because the
+// settings tree has many nested fields and a poll is cheap enough.
+
+import { onMounted, onUnmounted, reactive, ref } from 'vue';
+import { HttpUtil } from '@/utils';
+import { AllSetting } from '@/models/setting.js';
+
+const DIRTY_POLL_MS = 1000;
+
+export function useAllSetting() {
+  const fetched = ref(false);
+  const spinning = ref(false);
+  const saveDisabled = ref(true);
+
+  // Two reactive snapshots: the last server-side state and the one the
+  // user is editing. `equals` compares enumerable props field-by-field.
+  const oldAllSetting = reactive(new AllSetting());
+  const allSetting = reactive(new AllSetting());
+
+  function applyServerState(obj) {
+    const fresh = new AllSetting(obj);
+    Object.assign(oldAllSetting, fresh);
+    Object.assign(allSetting, fresh);
+    saveDisabled.value = true;
+  }
+
+  async function fetchAll() {
+    const msg = await HttpUtil.post('/panel/setting/all');
+    if (msg?.success) {
+      fetched.value = true;
+      applyServerState(msg.obj);
+    }
+  }
+
+  async function saveAll() {
+    spinning.value = true;
+    try {
+      const msg = await HttpUtil.post('/panel/setting/update', allSetting);
+      if (msg?.success) await fetchAll();
+    } finally {
+      spinning.value = false;
+    }
+  }
+
+  let timer = null;
+  function startDirtyPoll() {
+    if (timer != null) return;
+    timer = setInterval(() => {
+      // ObjectUtil.equals walks own enumerable props; reactive proxies
+      // expose them transparently so this works without cloning.
+      saveDisabled.value = oldAllSetting.equals(allSetting);
+    }, DIRTY_POLL_MS);
+  }
+  function stopDirtyPoll() {
+    if (timer != null) {
+      clearInterval(timer);
+      timer = null;
+    }
+  }
+
+  onMounted(() => {
+    fetchAll();
+    startDirtyPoll();
+  });
+  onUnmounted(stopDirtyPoll);
+
+  return {
+    fetched,
+    spinning,
+    saveDisabled,
+    oldAllSetting,
+    allSetting,
+    fetchAll,
+    saveAll,
+  };
+}

+ 465 - 0
frontend/src/pages/sub/SubPage.vue

@@ -0,0 +1,465 @@
+<script setup>
+import { computed, onMounted, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  SettingOutlined,
+  AndroidOutlined,
+  AppleOutlined,
+  DownOutlined,
+  CopyOutlined,
+} from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+import QRious from 'qrious';
+
+import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
+import {
+  theme as themeState,
+  antdThemeConfig,
+} from '@/composables/useTheme.js';
+import ThemeSwitchLogin from '@/components/ThemeSwitchLogin.vue';
+
+const { t } = useI18n();
+
+// Read the view-model Go injects via window.__SUB_PAGE_DATA__. Falls
+// back to safe defaults so the page still renders if the global is
+// missing (e.g. during local dev without the backend).
+const subData = window.__SUB_PAGE_DATA__ || {};
+
+const sId = subData.sId || '';
+const enabled = !!subData.enabled;
+const download = subData.download || '0';
+const upload = subData.upload || '0';
+const total = subData.total || '∞';
+const used = subData.used || '0';
+const remained = subData.remained || '';
+const totalByte = Number(subData.totalByte || 0);
+const expireMs = Number(subData.expire || 0) * 1000;
+const lastOnlineMs = Number(subData.lastOnline || 0);
+const subUrl = subData.subUrl || '';
+const subJsonUrl = subData.subJsonUrl || '';
+const subClashUrl = subData.subClashUrl || '';
+const links = Array.isArray(subData.links) ? subData.links : [];
+// Panel's "Calendar Type" setting; controls whether expiry / lastOnline
+// render in Gregorian or Jalali on this standalone subscription page.
+const datepicker = subData.datepicker || 'gregorian';
+
+// Derived state ===============================================
+const isUnlimited = computed(() => totalByte <= 0 && expireMs === 0);
+const isActive = computed(() => {
+  if (!enabled) return false;
+  if (totalByte > 0) {
+    const used = Number(subData.usedByte || 0)
+      || (Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0));
+    if (used >= totalByte) return false;
+  }
+  if (expireMs > 0 && Date.now() >= expireMs) return false;
+  return true;
+});
+
+// Mobile-aware layout — shows app dropdowns full-width below 576px
+const isMobile = ref(false);
+function updateMobile() { isMobile.value = window.innerWidth < 576; }
+onMounted(() => {
+  updateMobile();
+  window.addEventListener('resize', updateMobile);
+});
+
+// Language switcher mirrors the legacy panel: setting the language
+// triggers a full-page reload which re-renders with the new locale.
+const lang = ref(LanguageManager.getLanguage());
+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);
+});
+
+// Actions =====================================================
+async function copy(value) {
+  if (!value) return;
+  const ok = await ClipboardManager.copyText(value);
+  if (ok) message.success(t('copied'));
+}
+
+function open(url) {
+  if (!url) return;
+  window.open(url, '_blank');
+}
+
+// Pretty label per share link — pulls protocol + remark out of the
+// URL fragment (most clients put the remark after the # sign).
+function linkName(link, idx) {
+  if (!link) return `Link ${idx + 1}`;
+  const hashIdx = link.indexOf('#');
+  if (hashIdx >= 0 && hashIdx + 1 < link.length) {
+    try {
+      return decodeURIComponent(link.slice(hashIdx + 1));
+    } catch (_e) {
+      return link.slice(hashIdx + 1);
+    }
+  }
+  const proto = link.split('://')[0];
+  return `${proto.toUpperCase()} ${idx + 1}`;
+}
+
+// iOS deep links — taken verbatim from the legacy subpage. Each
+// client expects the sub URL in a slightly different param name.
+const shadowrocketUrl = computed(() => `sub://${btoa(subUrl)}`);
+const v2boxUrl = computed(() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`);
+const streisandUrl = computed(() => `streisand://import/${encodeURIComponent(subUrl)}`);
+const v2raytunUrl = computed(() => subUrl);
+const npvtunUrl = computed(() => subUrl);
+const happUrl = computed(() => `happ://add/${subUrl}`);
+
+// Theme classes for the page wrapper.
+const themeClass = computed(() => ({
+  'is-dark': themeState.isDark,
+  'is-ultra': themeState.isUltra,
+}));
+
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout class="subscription-page" :class="themeClass">
+      <a-layout-content class="content">
+        <a-row type="flex" justify="center">
+          <a-col :xs="24" :sm="22" :md="18" :lg="14" :xl="12">
+            <a-card hoverable class="subscription-card">
+              <template #title>
+                <a-space>
+                  <span>{{ t('subscription.title') }}</span>
+                  <a-tag>{{ sId }}</a-tag>
+                </a-space>
+              </template>
+              <template #extra>
+                <a-popover :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>
+              </template>
+
+              <!-- ============== QR codes ============== -->
+              <a-row :gutter="[8, 8]" justify="center" class="qr-row">
+                <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)"
+                    />
+                  </div>
+                </a-col>
+                <a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
+                  <div class="qr-box">
+                    <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)"
+                    />
+                  </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)"
+                    />
+                  </div>
+                </a-col>
+              </a-row>
+
+              <!-- ============== Subscription details ============== -->
+              <a-descriptions bordered :column="1" size="small" class="info-table">
+                <a-descriptions-item :label="t('subscription.subId')">{{ sId }}</a-descriptions-item>
+                <a-descriptions-item :label="t('subscription.status')">
+                  <a-tag v-if="!enabled" color="red">{{ t('subscription.inactive') }}</a-tag>
+                  <a-tag v-else-if="isUnlimited" color="purple">{{ t('subscription.unlimited') }}</a-tag>
+                  <a-tag v-else :color="isActive ? 'green' : 'red'">
+                    {{ isActive ? t('subscription.active') : t('subscription.inactive') }}
+                  </a-tag>
+                </a-descriptions-item>
+                <a-descriptions-item :label="t('subscription.downloaded')">{{ download }}</a-descriptions-item>
+                <a-descriptions-item :label="t('subscription.uploaded')">{{ upload }}</a-descriptions-item>
+                <a-descriptions-item :label="t('usage')">{{ used }}</a-descriptions-item>
+                <a-descriptions-item :label="t('subscription.totalQuota')">{{ total }}</a-descriptions-item>
+                <a-descriptions-item v-if="totalByte > 0" :label="t('remained')">
+                  {{ remained }}
+                </a-descriptions-item>
+                <a-descriptions-item :label="t('lastOnline')">
+                  <template v-if="lastOnlineMs > 0">{{ IntlUtil.formatDate(lastOnlineMs, datepicker) }}</template>
+                  <template v-else>-</template>
+                </a-descriptions-item>
+                <a-descriptions-item :label="t('subscription.expiry')">
+                  <template v-if="expireMs === 0">{{ t('subscription.noExpiry') }}</template>
+                  <template v-else>{{ IntlUtil.formatDate(expireMs, datepicker) }}</template>
+                </a-descriptions-item>
+              </a-descriptions>
+
+              <!-- ============== Individual links ============== -->
+              <div v-if="links.length" class="links-section">
+                <div
+                  v-for="(link, idx) in links"
+                  :key="link"
+                  class="link-row"
+                  @click="copy(link)"
+                >
+                  <a-tag color="purple" class="link-tag">{{ linkName(link, idx) }}</a-tag>
+                  <div class="link-box">
+                    <CopyOutlined class="link-copy-icon" />
+                    {{ link }}
+                  </div>
+                </div>
+              </div>
+
+              <!-- ============== App dropdowns ============== -->
+              <a-row :gutter="[8, 8]" justify="center" class="apps-row">
+                <a-col :xs="24" :sm="12" class="app-col">
+                  <a-dropdown :trigger="['click']">
+                    <a-button :block="isMobile" size="large" type="primary">
+                      <AndroidOutlined /> Android <DownOutlined />
+                    </a-button>
+                    <template #overlay>
+                      <a-menu>
+                        <a-menu-item key="android-v2box" @click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
+                        <a-menu-item key="android-v2rayng" @click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
+                        <a-menu-item key="android-singbox" @click="copy(subUrl)">Sing-box</a-menu-item>
+                        <a-menu-item key="android-v2raytun" @click="copy(subUrl)">V2RayTun</a-menu-item>
+                        <a-menu-item key="android-npvtunnel" @click="copy(subUrl)">NPV Tunnel</a-menu-item>
+                        <a-menu-item key="android-happ" @click="open(`happ://add/${subUrl}`)">Happ</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </a-col>
+                <a-col :xs="24" :sm="12" class="app-col">
+                  <a-dropdown :trigger="['click']">
+                    <a-button :block="isMobile" size="large" type="primary">
+                      <AppleOutlined /> iOS <DownOutlined />
+                    </a-button>
+                    <template #overlay>
+                      <a-menu>
+                        <a-menu-item key="ios-shadowrocket" @click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
+                        <a-menu-item key="ios-v2box" @click="open(v2boxUrl)">V2Box</a-menu-item>
+                        <a-menu-item key="ios-streisand" @click="open(streisandUrl)">Streisand</a-menu-item>
+                        <a-menu-item key="ios-v2raytun" @click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
+                        <a-menu-item key="ios-npvtunnel" @click="copy(npvtunUrl)">NPV Tunnel</a-menu-item>
+                        <a-menu-item key="ios-happ" @click="open(happUrl)">Happ</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </a-col>
+              </a-row>
+            </a-card>
+          </a-col>
+        </a-row>
+      </a-layout-content>
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.subscription-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+.subscription-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+.subscription-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+.subscription-page :deep(.ant-layout),
+.subscription-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content {
+  padding: 24px 12px;
+}
+
+.subscription-card {
+  margin-top: 8px;
+}
+
+/* QR section */
+.qr-row {
+  margin-bottom: 12px;
+}
+.qr-col {
+  display: flex;
+  justify-content: center;
+}
+.qr-box {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  width: 220px;
+}
+.qr-tag {
+  width: 100%;
+  text-align: center;
+  margin: 0;
+}
+.qr-canvas {
+  cursor: pointer;
+  background: #fff;
+  border-radius: 4px;
+}
+
+/* Description list spacing + visible borders. AD-Vue's default
+ * descriptions border is rgba(5,5,5,0.06) which disappears against
+ * the white card in light theme. AD-Vue puts the horizontal divider
+ * on <tr> with border-collapse:collapse — browsers treat <tr>
+ * borders inconsistently in collapse mode, so paint the divider on
+ * each cell's bottom edge instead. */
+.info-table {
+  margin-top: 12px;
+}
+.info-table :deep(.ant-descriptions-view),
+.info-table :deep(.ant-descriptions-view) table,
+.info-table :deep(.ant-descriptions-view) th,
+.info-table :deep(.ant-descriptions-view) td {
+  border-color: rgba(0, 0, 0, 0.18) !important;
+}
+.info-table :deep(tbody > tr > th),
+.info-table :deep(tbody > tr > td) {
+  border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
+}
+.info-table :deep(tbody > tr:last-child > th),
+.info-table :deep(tbody > tr:last-child > td) {
+  border-bottom: none !important;
+}
+
+.is-dark .info-table :deep(.ant-descriptions-view),
+.is-dark .info-table :deep(.ant-descriptions-view) table,
+.is-dark .info-table :deep(.ant-descriptions-view) th,
+.is-dark .info-table :deep(.ant-descriptions-view) td {
+  border-color: rgba(255, 255, 255, 0.18) !important;
+}
+.is-dark .info-table :deep(tbody > tr > th),
+.is-dark .info-table :deep(tbody > tr > td) {
+  border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
+}
+.is-dark .info-table :deep(tbody > tr:last-child > th),
+.is-dark .info-table :deep(tbody > tr:last-child > td) {
+  border-bottom: none !important;
+}
+
+/* Share links */
+.links-section {
+  margin-top: 16px;
+}
+.link-row {
+  position: relative;
+  margin-bottom: 16px;
+  text-align: center;
+}
+.link-tag {
+  margin-bottom: -10px;
+  position: relative;
+  z-index: 2;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+.link-box {
+  cursor: pointer;
+  border-radius: 12px;
+  padding: 22px 18px 14px;
+  margin-top: -10px;
+  word-break: break-all;
+  font-size: 13px;
+  line-height: 1.5;
+  text-align: left;
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+  transition: background 120ms ease, border-color 120ms ease;
+  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.08);
+  background: rgba(0, 0, 0, 0.03);
+  border: 1px solid rgba(0, 0, 0, 0.08);
+}
+.link-box:hover {
+  background: rgba(0, 0, 0, 0.05);
+  border-color: rgba(0, 0, 0, 0.14);
+}
+.link-copy-icon {
+  margin-right: 6px;
+  opacity: 0.6;
+}
+.is-dark .link-box {
+  background: rgba(0, 0, 0, 0.2);
+  border-color: rgba(255, 255, 255, 0.1);
+  color: rgba(255, 255, 255, 0.85);
+}
+.is-dark .link-box:hover {
+  background: rgba(0, 0, 0, 0.3);
+  border-color: rgba(255, 255, 255, 0.2);
+}
+
+/* App dropdown row */
+.apps-row {
+  margin-top: 24px;
+}
+.app-col {
+  text-align: center;
+}
+
+.settings-popover {
+  min-width: 220px;
+}
+.lang-select {
+  width: 100%;
+}
+</style>

+ 133 - 0
frontend/src/pages/xray/BalancerFormModal.vue

@@ -0,0 +1,133 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+const { t } = useI18n();
+
+// Balancer add/edit modal — mirrors xray_balancer_modal.html.
+// Tag must be unique across other balancers; selector is a tag-mode
+// list constrained to existing outbound tags (but lets users type
+// new ones for forward-references).
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  balancer: { type: Object, default: null },
+  outboundTags: { type: Array, default: () => [] },
+  // All other balancer tags (excludes the one currently being edited)
+  // — used for the duplicate-tag check.
+  otherTags: { type: Array, default: () => [] },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const STRATEGIES = [
+  { value: 'random', label: 'Random' },
+  { value: 'roundRobin', label: 'Round robin' },
+  { value: 'leastLoad', label: 'Least load' },
+  { value: 'leastPing', label: 'Least ping' },
+];
+
+const form = reactive({
+  tag: '',
+  strategy: 'random',
+  selector: [],
+  fallbackTag: '',
+});
+const isEdit = ref(false);
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (props.balancer) {
+    isEdit.value = true;
+    form.tag = props.balancer.tag || '';
+    form.strategy = props.balancer.strategy || 'random';
+    form.selector = [...(props.balancer.selector || [])];
+    form.fallbackTag = props.balancer.fallbackTag || '';
+  } else {
+    isEdit.value = false;
+    form.tag = '';
+    form.strategy = 'random';
+    form.selector = [];
+    form.fallbackTag = '';
+  }
+});
+
+const tagEmpty = computed(() => !form.tag?.trim());
+const duplicateTag = computed(
+  () => !!form.tag && props.otherTags.includes(form.tag.trim()),
+);
+const emptySelector = computed(() => form.selector.length === 0);
+const isValid = computed(
+  () => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
+);
+
+const tagValidateStatus = computed(() => {
+  if (tagEmpty.value) return 'error';
+  if (duplicateTag.value) return 'warning';
+  return 'success';
+});
+const tagHelp = computed(() => {
+  if (tagEmpty.value) return 'Tag is required';
+  if (duplicateTag.value) return 'Tag already used by another balancer';
+  return '';
+});
+
+const selectorValidateStatus = computed(() => (emptySelector.value ? 'error' : 'success'));
+const selectorHelp = computed(() => (emptySelector.value ? 'Pick at least one outbound' : ''));
+
+function close() { emit('update:open', false); }
+function onOk() {
+  if (!isValid.value) return;
+  emit('confirm', { ...form });
+}
+
+const title = computed(() =>
+  isEdit.value
+    ? `${t('edit')} ${t('pages.xray.Balancers')}`
+    : `+ ${t('pages.xray.Balancers')}`,
+);
+const okText = computed(() =>
+  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+);
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
+    :ok-button-props="{ disabled: !isValid }" :mask-closable="false" @ok="onOk" @cancel="close">
+    <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+      <a-form-item
+        label="Tag"
+        :validate-status="tagValidateStatus"
+        :help="tagHelp"
+        has-feedback
+      >
+        <a-input v-model:value="form.tag" placeholder="unique balancer tag" />
+      </a-form-item>
+
+      <a-form-item label="Strategy">
+        <a-select v-model:value="form.strategy">
+          <a-select-option v-for="s in STRATEGIES" :key="s.value" :value="s.value">{{ s.label }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item
+        label="Selector"
+        :validate-status="selectorValidateStatus"
+        :help="selectorHelp"
+        has-feedback
+      >
+        <a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
+          <a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item label="Fallback">
+        <a-select v-model:value="form.fallbackTag" allow-clear>
+          <a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
+            {{ tag || `(${t('none')})` }}
+          </a-select-option>
+        </a-select>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 210 - 0
frontend/src/pages/xray/BalancersTab.vue

@@ -0,0 +1,210 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  PlusOutlined,
+  MoreOutlined,
+  EditOutlined,
+  DeleteOutlined,
+} from '@ant-design/icons-vue';
+import { Modal } from 'ant-design-vue';
+
+import BalancerFormModal from './BalancerFormModal.vue';
+
+const { t } = useI18n();
+
+// Balancers tab — list + add/edit/delete over
+// templateSettings.routing.balancers. The legacy panel kept the wire
+// shape's `strategy: { type: 'random' }` nesting only when non-default;
+// we follow the same convention on submit.
+
+const props = defineProps({
+  templateSettings: { type: Object, default: null },
+});
+
+const STRATEGY_LABELS = {
+  random: 'Random',
+  roundRobin: 'Round robin',
+  leastLoad: 'Least load',
+  leastPing: 'Least ping',
+};
+
+const rows = computed(() => {
+  const list = props.templateSettings?.routing?.balancers || [];
+  return list.map((b, idx) => ({
+    key: idx,
+    tag: b.tag || '',
+    strategy: b.strategy?.type || 'random',
+    selector: b.selector || [],
+    fallbackTag: b.fallbackTag || '',
+  }));
+});
+
+const outboundTags = computed(
+  () => (props.templateSettings?.outbounds || [])
+    .filter((o) => o.tag)
+    .map((o) => o.tag),
+);
+
+// === Modal state ====================================================
+const modalOpen = ref(false);
+const editingBalancer = ref(null);
+const editingIndex = ref(null);
+const otherTags = ref([]);
+
+function tagPool(excludeIdx) {
+  return rows.value.filter((b) => b.key !== excludeIdx).map((b) => b.tag).filter(Boolean);
+}
+
+function openAdd() {
+  editingBalancer.value = null;
+  editingIndex.value = null;
+  otherTags.value = rows.value.map((b) => b.tag).filter(Boolean);
+  modalOpen.value = true;
+}
+function openEdit(idx) {
+  editingBalancer.value = rows.value[idx];
+  editingIndex.value = idx;
+  otherTags.value = tagPool(idx);
+  modalOpen.value = true;
+}
+
+function ensureBalancersArray() {
+  if (!props.templateSettings.routing) return null;
+  if (!Array.isArray(props.templateSettings.routing.balancers)) {
+    props.templateSettings.routing.balancers = [];
+  }
+  return props.templateSettings.routing.balancers;
+}
+
+function buildWireBalancer(form) {
+  const out = {
+    tag: form.tag,
+    selector: [...form.selector],
+    fallbackTag: form.fallbackTag,
+  };
+  if (form.strategy && form.strategy !== 'random') {
+    out.strategy = { type: form.strategy };
+  }
+  return out;
+}
+
+function onConfirm(form) {
+  const arr = ensureBalancersArray();
+  if (!arr) return;
+
+  const wire = buildWireBalancer(form);
+  if (editingIndex.value == null) {
+    arr.push(wire);
+  } else {
+    const oldTag = arr[editingIndex.value]?.tag;
+    arr[editingIndex.value] = wire;
+    // Preserve the legacy behaviour: when a balancer's tag is renamed,
+    // chase the rename across routing rules so existing references
+    // don't dangle.
+    if (oldTag && oldTag !== wire.tag) {
+      const rules = props.templateSettings.routing.rules || [];
+      for (const rule of rules) {
+        if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
+      }
+    }
+  }
+  modalOpen.value = false;
+}
+
+function confirmDelete(idx) {
+  Modal.confirm({
+    title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    // Wrap in a block so we discard splice's return value — AD-Vue
+    // 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); },
+  });
+}
+
+const columns = computed(() => [
+  { title: '#', key: 'action', align: 'center', width: 80 },
+  { title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
+  { title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
+  { title: 'Selector', key: 'selector', align: 'center' },
+  { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
+]);
+</script>
+
+<template>
+  <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+    <a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
+      <a-button type="primary" @click="openAdd">
+        <template #icon>
+          <PlusOutlined />
+        </template>
+        {{ t('pages.xray.Balancers') }}
+      </a-button>
+    </a-empty>
+
+    <template v-else>
+      <a-button type="primary" @click="openAdd">
+        <template #icon>
+          <PlusOutlined />
+        </template>
+        {{ t('pages.xray.Balancers') }}
+      </a-button>
+
+      <a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
+        <template #bodyCell="{ column, record, index }">
+          <template v-if="column.key === 'action'">
+            <span class="row-index">{{ index + 1 }}</span>
+            <a-dropdown :trigger="['click']">
+              <a-button shape="circle" size="small" class="action-btn">
+                <MoreOutlined />
+              </a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item @click="openEdit(index)">
+                    <EditOutlined /> {{ t('edit') }}
+                  </a-menu-item>
+                  <a-menu-item class="danger" @click="confirmDelete(index)">
+                    <DeleteOutlined /> {{ t('delete') }}
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </template>
+
+          <template v-else-if="column.key === 'strategy'">
+            <a-tag :color="record.strategy === 'random' ? 'purple' : 'green'">
+              {{ STRATEGY_LABELS[record.strategy] || record.strategy }}
+            </a-tag>
+          </template>
+
+          <template v-else-if="column.key === 'selector'">
+            <a-tag v-for="sel in record.selector" :key="sel" class="info-large-tag">{{ sel }}</a-tag>
+          </template>
+        </template>
+      </a-table>
+    </template>
+
+    <BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
+      :other-tags="otherTags" @confirm="onConfirm" />
+  </a-space>
+</template>
+
+<style scoped>
+.row-index {
+  font-weight: 500;
+  opacity: 0.7;
+  margin-right: 6px;
+}
+
+.action-btn {
+  vertical-align: middle;
+}
+
+.danger {
+  color: #ff4d4f;
+}
+</style>

+ 500 - 0
frontend/src/pages/xray/BasicsTab.vue

@@ -0,0 +1,500 @@
+<script setup>
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
+import { Modal } from 'ant-design-vue';
+
+import { OutboundDomainStrategies } from '@/models/outbound.js';
+import SettingListItem from '@/components/SettingListItem.vue';
+
+const { t } = useI18n();
+
+// Phase 6-ii: structured editor for the most-touched fields of the
+// xray template — outbound strategy, routing strategy, log levels,
+// stat counters, and the "basic routing" lists (block IPs/domains/
+// torrent + direct IPs/domains + IPv4 forced + warp/nord domains).
+//
+// Mutates the parent's templateSettings reactive directly. The
+// useXraySetting composable's deep watch on templateSettings re-
+// stringifies into xraySetting so the Advanced JSON tab and the
+// dirty-poll see every edit.
+
+const props = defineProps({
+  templateSettings: { type: Object, default: null },
+  outboundTestUrl: { type: String, default: '' },
+  warpExist: { type: Boolean, default: false },
+  nordExist: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:outbound-test-url', 'show-warp', 'show-nord', 'reset-default']);
+
+function confirmResetDefault() {
+  Modal.confirm({
+    title: t('pages.settings.resetDefaultConfig'),
+    okText: t('reset'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: () => { emit('reset-default'); },
+  });
+}
+
+// === Static option lists (mirror legacy) =============================
+const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
+const LOG_LEVELS = ['none', 'debug', 'info', 'warning', 'error'];
+const ACCESS_LOG = ['none', './access.log'];
+const ERROR_LOG = ['none', './error.log'];
+const MASK_ADDRESS = ['quarter', 'half', 'full'];
+const BITTORRENT_PROTOCOLS = ['bittorrent'];
+
+// Country / service lists mirror the legacy panel's settingsData
+// (web/html/xray.html on main). Keep additions in sync with that file
+// so Vue 3 + legacy stay swappable while the migration finishes.
+const IPS_OPTIONS = [
+  { label: 'Private IPs', value: 'geoip:private' },
+  { label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
+  { label: '🇨🇳 China', value: 'geoip:cn' },
+  { label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
+  { label: '🇻🇳 Vietnam', value: 'geoip:vn' },
+  { label: '🇪🇸 Spain', value: 'geoip:es' },
+  { label: '🇮🇩 Indonesia', value: 'geoip:id' },
+  { label: '🇺🇦 Ukraine', value: 'geoip:ua' },
+  { label: '🇹🇷 Türkiye', value: 'geoip:tr' },
+  { label: '🇧🇷 Brazil', value: 'geoip:br' },
+];
+const DOMAINS_OPTIONS = [
+  { label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
+  { label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
+  { label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
+  { label: '🇨🇳 China', value: 'geosite:cn' },
+  { label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
+  { label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
+  { label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
+  { label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
+  { label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
+  { label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
+];
+const BLOCK_DOMAINS_OPTIONS = [
+  { label: 'Ads All', value: 'geosite:category-ads-all' },
+  { label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
+  { label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
+  { label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
+  { label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
+  { label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
+  { label: 'Adult +18', value: 'geosite:category-porn' },
+  { label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
+  { label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
+  { label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
+  { label: '🇨🇳 China', value: 'geosite:cn' },
+  { label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
+  { label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
+  { label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
+  { label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
+  { label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
+  { label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
+];
+const SERVICES_OPTIONS = [
+  { label: 'Apple', value: 'geosite:apple' },
+  { label: 'Meta', value: 'geosite:meta' },
+  { label: 'Google', value: 'geosite:google' },
+  { label: 'OpenAI', value: 'geosite:openai' },
+  { label: 'Spotify', value: 'geosite:spotify' },
+  { label: 'Netflix', value: 'geosite:netflix' },
+  { label: 'Reddit', value: 'geosite:reddit' },
+  { label: 'Speedtest', value: 'geosite:speedtest' },
+];
+
+// === Routing-rule helpers (matches legacy templateRule{Getter,Setter}) ==
+function ruleGetter(outboundTag, property) {
+  if (!props.templateSettings?.routing?.rules) return [];
+  const out = [];
+  for (const rule of props.templateSettings.routing.rules) {
+    if (
+      rule
+      && Object.prototype.hasOwnProperty.call(rule, property)
+      && Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
+      && rule.outboundTag === outboundTag
+    ) {
+      out.push(...rule[property]);
+    }
+  }
+  return out;
+}
+function ruleSetter(outboundTag, property, data) {
+  if (!props.templateSettings?.routing) return;
+  const current = ruleGetter(outboundTag, property);
+  if (current.length === 0) {
+    props.templateSettings.routing.rules.push({
+      type: 'field',
+      outboundTag,
+      [property]: data,
+    });
+    return;
+  }
+  // Replace the property on the FIRST matching rule and drop any later
+  // duplicates with the same (outboundTag, property) pair (matches the
+  // legacy single-write-then-filter behavior).
+  const next = [];
+  let inserted = false;
+  for (const rule of props.templateSettings.routing.rules) {
+    const matches =
+      rule
+      && Object.prototype.hasOwnProperty.call(rule, property)
+      && Object.prototype.hasOwnProperty.call(rule, 'outboundTag')
+      && rule.outboundTag === outboundTag;
+    if (matches) {
+      if (!inserted && data.length > 0) {
+        rule[property] = data;
+        next.push(rule);
+        inserted = true;
+      }
+    } else {
+      next.push(rule);
+    }
+  }
+  props.templateSettings.routing.rules = next;
+}
+
+function syncOutbound(tag, settings) {
+  // After editing direct/IPv4/warp/nord rules, ensure the matching
+  // outbound exists when the rule list has any entries, and is
+  // pruned when none remain (legacy syncRulesWithOutbound).
+  const t = props.templateSettings;
+  if (!t) return;
+  const haveRules = t.routing.rules.some((r) => r?.outboundTag === tag);
+  const idx = t.outbounds.findIndex((o) => o.tag === tag);
+  if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
+  if (haveRules && idx < 0) t.outbounds.push(settings);
+}
+
+// === Computed v-models for every Basics field ========================
+function rule(tag, property, syncFn) {
+  return computed({
+    get: () => ruleGetter(tag, property),
+    set: (next) => { ruleSetter(tag, property, next); if (syncFn) syncFn(); },
+  });
+}
+
+const directSettings = { tag: 'direct', protocol: 'freedom' };
+const ipv4Settings = { tag: 'IPv4', protocol: 'freedom', settings: { domainStrategy: 'UseIPv4' } };
+
+const freedomStrategy = computed({
+  get: () => {
+    const ob = props.templateSettings?.outbounds?.find(
+      (o) => o.protocol === 'freedom' && o.tag === 'direct',
+    );
+    return ob?.settings?.domainStrategy ?? 'AsIs';
+  },
+  set: (next) => {
+    const t = props.templateSettings;
+    if (!t) return;
+    const idx = t.outbounds.findIndex((o) => o.protocol === 'freedom' && o.tag === 'direct');
+    if (idx < 0) {
+      t.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: { domainStrategy: next } });
+    } else {
+      t.outbounds[idx].settings = t.outbounds[idx].settings || {};
+      t.outbounds[idx].settings.domainStrategy = next;
+    }
+  },
+});
+
+const routingStrategy = computed({
+  get: () => props.templateSettings?.routing?.domainStrategy ?? 'AsIs',
+  set: (next) => { if (props.templateSettings?.routing) props.templateSettings.routing.domainStrategy = next; },
+});
+
+function logField(field, fallback) {
+  return computed({
+    get: () => props.templateSettings?.log?.[field] ?? fallback,
+    set: (next) => { if (props.templateSettings?.log) props.templateSettings.log[field] = next; },
+  });
+}
+const logLevel = logField('loglevel', 'warning');
+const accessLog = logField('access', '');
+const errorLog = logField('error', '');
+const maskAddressLog = logField('maskAddress', '');
+const dnslog = logField('dnsLog', false);
+
+function policyField(field) {
+  return computed({
+    get: () => !!props.templateSettings?.policy?.system?.[field],
+    set: (next) => {
+      if (!props.templateSettings?.policy?.system) return;
+      props.templateSettings.policy.system[field] = next;
+    },
+  });
+}
+const statsInboundUplink = policyField('statsInboundUplink');
+const statsInboundDownlink = policyField('statsInboundDownlink');
+const statsOutboundUplink = policyField('statsOutboundUplink');
+const statsOutboundDownlink = policyField('statsOutboundDownlink');
+
+const blockedIPs = rule('blocked', 'ip');
+const blockedDomains = rule('blocked', 'domain');
+const blockedProtocols = rule('blocked', 'protocol');
+const directIPs = rule('direct', 'ip', () => syncOutbound('direct', directSettings));
+const directDomains = rule('direct', 'domain', () => syncOutbound('direct', directSettings));
+const ipv4Domains = rule('IPv4', 'domain', () => syncOutbound('IPv4', ipv4Settings));
+const warpDomains = rule('warp', 'domain');
+const nordTag = computed(() => {
+  const ob = props.templateSettings?.outbounds?.find((o) => o.tag?.startsWith?.('nord-'));
+  return ob?.tag || 'nord';
+});
+const nordDomains = computed({
+  get: () => ruleGetter(nordTag.value, 'domain'),
+  set: (next) => ruleSetter(nordTag.value, 'domain', next),
+});
+
+const torrentSettings = computed({
+  get: () => BITTORRENT_PROTOCOLS.every((p) => blockedProtocols.value.includes(p)),
+  set: (next) => {
+    if (next) {
+      blockedProtocols.value = [...blockedProtocols.value, ...BITTORRENT_PROTOCOLS];
+    } else {
+      blockedProtocols.value = blockedProtocols.value.filter((d) => !BITTORRENT_PROTOCOLS.includes(d));
+    }
+  },
+});
+
+const localOutboundTestUrl = computed({
+  get: () => props.outboundTestUrl,
+  set: (next) => emit('update:outbound-test-url', next),
+});
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
+      <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.generalConfigsDesc')">
+        <template #icon>
+          <ExclamationCircleFilled style="color: #FFA031;" />
+        </template>
+      </a-alert>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.FreedomStrategy') }}</template>
+        <template #description>{{ t('pages.xray.FreedomStrategyDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="freedomStrategy" :style="{ width: '100%' }">
+            <a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.RoutingStrategy') }}</template>
+        <template #description>{{ t('pages.xray.RoutingStrategyDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="routingStrategy" :style="{ width: '100%' }">
+            <a-select-option v-for="s in ROUTING_DOMAIN_STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.outboundTestUrl') }}</template>
+        <template #description>{{ t('pages.xray.outboundTestUrlDesc') }}</template>
+        <template #control>
+          <a-input v-model:value="localOutboundTestUrl" placeholder="https://www.google.com/generate_204" />
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="2" :header="t('pages.xray.statistics')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.statsInboundUplink') }}</template>
+        <template #control><a-switch v-model:checked="statsInboundUplink" /></template>
+      </SettingListItem>
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.statsInboundDownlink') }}</template>
+        <template #control><a-switch v-model:checked="statsInboundDownlink" /></template>
+      </SettingListItem>
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.statsOutboundUplink') }}</template>
+        <template #control><a-switch v-model:checked="statsOutboundUplink" /></template>
+      </SettingListItem>
+      <SettingListItem paddings="small">
+        <template #title>Outbound downlink stats</template>
+        <template #control><a-switch v-model:checked="statsOutboundDownlink" /></template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="3" :header="t('pages.xray.logConfigs')">
+      <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.logConfigsDesc')">
+        <template #icon>
+          <ExclamationCircleFilled style="color: #FFA031;" />
+        </template>
+      </a-alert>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.logLevel') }}</template>
+        <template #description>{{ t('pages.xray.logLevelDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="logLevel" :style="{ width: '100%' }">
+            <a-select-option v-for="s in LOG_LEVELS" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.accessLog') }}</template>
+        <template #description>{{ t('pages.xray.accessLogDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="accessLog" :style="{ width: '100%' }">
+            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.errorLog') }}</template>
+        <template #description>{{ t('pages.xray.errorLogDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="errorLog" :style="{ width: '100%' }">
+            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.maskAddress') }}</template>
+        <template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
+            <a-select-option value="">{{ t('none') }}</a-select-option>
+            <a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.dnsLog') }}</template>
+        <template #description>{{ t('pages.xray.dnsLogDesc') }}</template>
+        <template #control><a-switch v-model:checked="dnslog" /></template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="4" :header="t('pages.xray.basicRouting')">
+      <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.blockConnectionsConfigsDesc')">
+        <template #icon>
+          <ExclamationCircleFilled style="color: #FFA031;" />
+        </template>
+      </a-alert>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.Torrent') }}</template>
+        <template #control><a-switch v-model:checked="torrentSettings" /></template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.blockips') }}</template>
+        <template #control>
+          <a-select v-model:value="blockedIPs" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.blockdomains') }}</template>
+        <template #control>
+          <a-select v-model:value="blockedDomains" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{
+              p.label }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.directConnectionsConfigsDesc')">
+        <template #icon>
+          <ExclamationCircleFilled style="color: #FFA031;" />
+        </template>
+      </a-alert>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.directips') }}</template>
+        <template #control>
+          <a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.directdomains') }}</template>
+        <template #control>
+          <a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.ipv4Routing') }}</template>
+        <template #description>{{ t('pages.xray.ipv4RoutingDesc') }}</template>
+        <template #control>
+          <a-select v-model:value="ipv4Domains" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.warpRouting') }}</template>
+        <template #description>{{ t('pages.xray.warpRoutingDesc') }}</template>
+        <template #control>
+          <a-select v-if="warpExist" v-model:value="warpDomains" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+          <a-button v-else type="primary" @click="emit('show-warp')">
+            <template #icon>
+              <CloudOutlined />
+            </template>
+            WARP
+          </a-button>
+        </template>
+      </SettingListItem>
+
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.nordRouting') }}</template>
+        <template #description>{{ t('pages.xray.nordRoutingDesc') }}</template>
+        <template #control>
+          <a-select v-if="nordExist" v-model:value="nordDomains" mode="tags" :style="{ width: '100%' }">
+            <a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
+            }}</a-select-option>
+          </a-select>
+          <a-button v-else type="primary" @click="emit('show-nord')">
+            <template #icon>
+              <ApiOutlined />
+            </template>
+            NordVPN
+          </a-button>
+        </template>
+      </SettingListItem>
+    </a-collapse-panel>
+
+    <a-collapse-panel key="reset" :header="t('pages.settings.resetDefaultConfig')">
+      <a-space direction="horizontal" :style="{ padding: '0 20px' }">
+        <a-button danger @click="confirmResetDefault">
+          {{ t('pages.settings.resetDefaultConfig') }}
+        </a-button>
+      </a-space>
+    </a-collapse-panel>
+  </a-collapse>
+</template>
+
+<style scoped>
+.mb-12 {
+  margin-bottom: 12px;
+}
+
+.hint-alert {
+  text-align: center;
+}
+</style>

+ 168 - 0
frontend/src/pages/xray/DnsServerModal.vue

@@ -0,0 +1,168 @@
+<script setup>
+import { computed, reactive, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
+
+const { t } = useI18n();
+
+// DNS server add/edit modal — mirrors web/html/modals/xray_dns_modal.html.
+// The legacy panel allowed both string-form ("8.8.8.8") and object-form
+// servers; we always edit as an object and the parent can decide
+// whether to collapse to a string when nothing besides address is set.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  server: { type: [Object, String, null], default: null },
+  isEdit: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const DEFAULT_SERVER = () => ({
+  address: 'localhost',
+  port: 53,
+  domains: [],
+  expectIPs: [],
+  unexpectedIPs: [],
+  queryStrategy: 'UseIP',
+  skipFallback: true,
+  disableCache: false,
+  finalQuery: false,
+});
+
+const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+
+const form = reactive(DEFAULT_SERVER());
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  Object.assign(form, DEFAULT_SERVER());
+  if (props.server == null) return;
+  if (typeof props.server === 'string') {
+    form.address = props.server;
+    return;
+  }
+  // Object — copy fields, defaulting missing arrays to empty.
+  Object.assign(form, {
+    ...DEFAULT_SERVER(),
+    ...props.server,
+    domains: [...(props.server.domains || [])],
+    expectIPs: [...(props.server.expectIPs || [])],
+    unexpectedIPs: [...(props.server.unexpectedIPs || [])],
+  });
+});
+
+function close() { emit('update:open', false); }
+
+function onOk() {
+  // If the user only set an address (everything else default), emit a
+  // bare string — that's the wire shape the legacy panel uses for
+  // servers like "8.8.8.8" and keeps the JSON tidy.
+  const isPlain = form.domains.length === 0
+    && form.expectIPs.length === 0
+    && form.unexpectedIPs.length === 0
+    && form.port === 53
+    && form.queryStrategy === 'UseIP'
+    && form.skipFallback === true
+    && form.disableCache === false
+    && form.finalQuery === false;
+  if (isPlain) {
+    emit('confirm', form.address);
+  } else {
+    emit('confirm', {
+      address: form.address,
+      port: form.port,
+      domains: [...form.domains].filter(Boolean),
+      expectIPs: [...form.expectIPs].filter(Boolean),
+      unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
+      queryStrategy: form.queryStrategy,
+      skipFallback: form.skipFallback,
+      disableCache: form.disableCache,
+      finalQuery: form.finalQuery,
+    });
+  }
+}
+
+const title = computed(() =>
+  props.isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add'),
+);
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    :title="title"
+    :ok-text="t('confirm')"
+    :cancel-text="t('close')"
+    :mask-closable="false"
+    @ok="onOk"
+    @cancel="close"
+  >
+    <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+      <a-form-item :label="t('pages.inbounds.address')">
+        <a-input v-model:value="form.address" />
+      </a-form-item>
+      <a-form-item :label="t('pages.inbounds.port')">
+        <a-input-number v-model:value="form.port" :min="1" :max="65535" />
+      </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.strategy')">
+        <a-select v-model:value="form.queryStrategy" :style="{ width: '100%' }">
+          <a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-divider :style="{ margin: '5px 0' }" />
+
+      <a-form-item :label="t('pages.xray.dns.domains')">
+        <a-button size="small" type="primary" @click="form.domains.push('')">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+        <template v-for="(_, idx) in form.domains" :key="`d${idx}`">
+          <a-input v-model:value="form.domains[idx]" :style="{ marginTop: '4px' }">
+            <template #addonAfter>
+              <MinusOutlined @click="form.domains.splice(idx, 1)" />
+            </template>
+          </a-input>
+        </template>
+      </a-form-item>
+
+      <a-form-item :label="t('pages.xray.dns.expectIPs')">
+        <a-button size="small" type="primary" @click="form.expectIPs.push('')">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+        <template v-for="(_, idx) in form.expectIPs" :key="`e${idx}`">
+          <a-input v-model:value="form.expectIPs[idx]" :style="{ marginTop: '4px' }">
+            <template #addonAfter>
+              <MinusOutlined @click="form.expectIPs.splice(idx, 1)" />
+            </template>
+          </a-input>
+        </template>
+      </a-form-item>
+
+      <a-form-item :label="t('pages.xray.dns.unexpectIPs')">
+        <a-button size="small" type="primary" @click="form.unexpectedIPs.push('')">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+        <template v-for="(_, idx) in form.unexpectedIPs" :key="`u${idx}`">
+          <a-input v-model:value="form.unexpectedIPs[idx]" :style="{ marginTop: '4px' }">
+            <template #addonAfter>
+              <MinusOutlined @click="form.unexpectedIPs.splice(idx, 1)" />
+            </template>
+          </a-input>
+        </template>
+      </a-form-item>
+
+      <a-divider :style="{ margin: '5px 0' }" />
+
+      <a-form-item label="Skip fallback">
+        <a-switch v-model:checked="form.skipFallback" />
+      </a-form-item>
+      <a-form-item :label="t('pages.xray.dns.disableCache')">
+        <a-switch v-model:checked="form.disableCache" />
+      </a-form-item>
+      <a-form-item label="Final query">
+        <a-switch v-model:checked="form.finalQuery" />
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>

+ 373 - 0
frontend/src/pages/xray/DnsTab.vue

@@ -0,0 +1,373 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  PlusOutlined,
+  MoreOutlined,
+  EditOutlined,
+  DeleteOutlined,
+} from '@ant-design/icons-vue';
+
+import SettingListItem from '@/components/SettingListItem.vue';
+import DnsServerModal from './DnsServerModal.vue';
+
+const { t } = useI18n();
+
+// Structured DNS editor — mirrors web/html/settings/xray/dns.html.
+// Master enable switch + general DNS options + per-server table with
+// add/edit/delete (modal flow), plus a Fake DNS table. Both lists
+// flow through templateSettings.dns / .fakedns reactively so the
+// useXraySetting composable picks every edit up via its deep watch.
+
+const props = defineProps({
+  templateSettings: { type: Object, default: null },
+});
+
+const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
+
+// ============== Master toggle ==============
+const enableDNS = computed({
+  get: () => !!props.templateSettings?.dns,
+  set: (next) => {
+    if (!props.templateSettings) return;
+    if (next) {
+      props.templateSettings.dns = {
+        tag: 'dns_inbound',
+        clientIp: '',
+        queryStrategy: 'UseIP',
+        disableCache: false,
+        disableFallback: false,
+        disableFallbackIfMatch: false,
+        useSystemHosts: false,
+        enableParallelQuery: false,
+        servers: [],
+      };
+      props.templateSettings.fakedns = null;
+    } else {
+      delete props.templateSettings.dns;
+      delete props.templateSettings.fakedns;
+    }
+  },
+});
+
+// ============== Field bridges ==============
+function dnsField(field, fallback) {
+  return computed({
+    get: () => props.templateSettings?.dns?.[field] ?? fallback,
+    set: (v) => {
+      if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
+    },
+  });
+}
+
+const dnsTag = dnsField('tag', 'dns_inbound');
+const dnsClientIp = dnsField('clientIp', '');
+const dnsStrategy = dnsField('queryStrategy', 'UseIP');
+const dnsDisableCache = dnsField('disableCache', false);
+const dnsDisableFallback = dnsField('disableFallback', false);
+const dnsDisableFallbackIfMatch = dnsField('disableFallbackIfMatch', false);
+const dnsEnableParallelQuery = dnsField('enableParallelQuery', false);
+const dnsUseSystemHosts = dnsField('useSystemHosts', false);
+
+// ============== DNS server table ==============
+const dnsServers = computed(() => {
+  const list = props.templateSettings?.dns?.servers || [];
+  return list.map((s, idx) => ({ key: idx, server: s }));
+});
+
+const dnsColumns = computed(() => [
+  { title: '#', key: 'action', align: 'center', width: 60 },
+  { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
+  { title: t('pages.xray.dns.domains'), key: 'domains', align: 'left' },
+  { title: t('pages.xray.dns.expectIPs'), key: 'expectIPs', align: 'left' },
+]);
+
+function addrFor(server) {
+  return typeof server === 'string' ? server : server?.address || '';
+}
+function domainsFor(server) {
+  return typeof server === 'object' ? (server.domains || []).join(',') : '';
+}
+function expectIPsFor(server) {
+  return typeof server === 'object' ? (server.expectIPs || []).join(',') : '';
+}
+
+// ============== Server modal ==============
+const serverModalOpen = ref(false);
+const editingServer = ref(null);
+const editingIndex = ref(null);
+
+function openAddServer() {
+  editingServer.value = null;
+  editingIndex.value = null;
+  serverModalOpen.value = true;
+}
+function openEditServer(idx) {
+  editingServer.value = props.templateSettings.dns.servers[idx];
+  editingIndex.value = idx;
+  serverModalOpen.value = true;
+}
+function onServerConfirm(value) {
+  if (!props.templateSettings?.dns) return;
+  if (!Array.isArray(props.templateSettings.dns.servers)) {
+    props.templateSettings.dns.servers = [];
+  }
+  if (editingIndex.value == null) {
+    props.templateSettings.dns.servers.push(value);
+  } else {
+    props.templateSettings.dns.servers[editingIndex.value] = value;
+  }
+  serverModalOpen.value = false;
+}
+function deleteServer(idx) {
+  props.templateSettings.dns.servers.splice(idx, 1);
+}
+
+// ============== Fake DNS table ==============
+const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
+
+const fakeDnsList = computed(() => {
+  const list = Array.isArray(props.templateSettings?.fakedns)
+    ? props.templateSettings.fakedns
+    : [];
+  return list.map((entry, idx) => ({ key: idx, ...entry }));
+});
+
+const fakednsColumns = computed(() => [
+  { title: '#', key: 'action', align: 'center', width: 60 },
+  { title: 'IP pool', dataIndex: 'ipPool', key: 'ipPool', align: 'left' },
+  { title: 'Pool size', dataIndex: 'poolSize', key: 'poolSize', align: 'right', width: 120 },
+]);
+
+function addFakedns() {
+  if (!props.templateSettings) return;
+  if (!Array.isArray(props.templateSettings.fakedns)) {
+    props.templateSettings.fakedns = [];
+  }
+  props.templateSettings.fakedns.push(DEFAULT_FAKEDNS());
+}
+function deleteFakedns(idx) {
+  props.templateSettings.fakedns.splice(idx, 1);
+  if (props.templateSettings.fakedns.length === 0) {
+    props.templateSettings.fakedns = null;
+  }
+}
+function updateFakednsField(idx, field, value) {
+  if (!props.templateSettings.fakedns?.[idx]) return;
+  props.templateSettings.fakedns[idx] = {
+    ...props.templateSettings.fakedns[idx],
+    [field]: value,
+  };
+}
+</script>
+
+<template>
+  <a-collapse default-active-key="1">
+    <!-- ============== General DNS settings ============== -->
+    <a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
+      <SettingListItem paddings="small">
+        <template #title>{{ t('pages.xray.dns.enable') }}</template>
+        <template #description>{{ t('pages.xray.dns.enableDesc') }}</template>
+        <template #control>
+          <a-switch v-model:checked="enableDNS" />
+        </template>
+      </SettingListItem>
+
+      <template v-if="enableDNS">
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.tag') }}</template>
+          <template #description>{{ t('pages.xray.dns.tagDesc') }}</template>
+          <template #control>
+            <a-input v-model:value="dnsTag" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.clientIp') }}</template>
+          <template #description>{{ t('pages.xray.dns.clientIpDesc') }}</template>
+          <template #control>
+            <a-input v-model:value="dnsClientIp" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.strategy') }}</template>
+          <template #description>{{ t('pages.xray.dns.strategyDesc') }}</template>
+          <template #control>
+            <a-select v-model:value="dnsStrategy" :style="{ width: '100%' }">
+              <a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
+            </a-select>
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.disableCache') }}</template>
+          <template #description>{{ t('pages.xray.dns.disableCacheDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsDisableCache" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.disableFallback') }}</template>
+          <template #description>{{ t('pages.xray.dns.disableFallbackDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsDisableFallback" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.disableFallbackIfMatch') }}</template>
+          <template #description>{{ t('pages.xray.dns.disableFallbackIfMatchDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsDisableFallbackIfMatch" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.enableParallelQuery') }}</template>
+          <template #description>{{ t('pages.xray.dns.enableParallelQueryDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsEnableParallelQuery" />
+          </template>
+        </SettingListItem>
+
+        <SettingListItem paddings="small">
+          <template #title>{{ t('pages.xray.dns.useSystemHosts') }}</template>
+          <template #description>{{ t('pages.xray.dns.useSystemHostsDesc') }}</template>
+          <template #control>
+            <a-switch v-model:checked="dnsUseSystemHosts" />
+          </template>
+        </SettingListItem>
+      </template>
+    </a-collapse-panel>
+
+    <!-- ============== DNS servers ============== -->
+    <a-collapse-panel v-if="enableDNS" key="2" header="DNS">
+      <a-empty v-if="dnsServers.length === 0" :description="t('emptyDnsDesc')">
+        <a-button type="primary" @click="openAddServer">
+          <template #icon><PlusOutlined /></template>
+          {{ t('pages.xray.dns.add') }}
+        </a-button>
+      </a-empty>
+
+      <template v-else>
+        <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+          <a-button type="primary" @click="openAddServer">
+            <template #icon><PlusOutlined /></template>
+            {{ t('pages.xray.dns.add') }}
+          </a-button>
+          <a-table
+            :columns="dnsColumns"
+            :data-source="dnsServers"
+            :row-key="(r) => r.key"
+            :pagination="false"
+            size="small"
+            bordered
+          >
+            <template #bodyCell="{ column, record, index }">
+              <template v-if="column.key === 'action'">
+                <a-space :size="6">
+                  <span class="row-index">{{ index + 1 }}</span>
+                  <a-dropdown :trigger="['click']">
+                    <a-button shape="circle" size="small">
+                      <MoreOutlined />
+                    </a-button>
+                    <template #overlay>
+                      <a-menu>
+                        <a-menu-item @click="openEditServer(index)">
+                          <EditOutlined /> {{ t('edit') }}
+                        </a-menu-item>
+                        <a-menu-item class="danger" @click="deleteServer(index)">
+                          <DeleteOutlined /> {{ t('delete') }}
+                        </a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </a-space>
+              </template>
+              <template v-else-if="column.key === 'address'">
+                {{ addrFor(record.server) }}
+              </template>
+              <template v-else-if="column.key === 'domains'">
+                <span class="muted">{{ domainsFor(record.server) }}</span>
+              </template>
+              <template v-else-if="column.key === 'expectIPs'">
+                <span class="muted">{{ expectIPsFor(record.server) }}</span>
+              </template>
+            </template>
+          </a-table>
+        </a-space>
+      </template>
+    </a-collapse-panel>
+
+    <!-- ============== Fake DNS ============== -->
+    <a-collapse-panel v-if="enableDNS" key="3" header="Fake DNS">
+      <a-empty v-if="fakeDnsList.length === 0" :description="t('emptyFakeDnsDesc')">
+        <a-button type="primary" @click="addFakedns">
+          <template #icon><PlusOutlined /></template>
+          {{ t('pages.xray.fakedns.add') }}
+        </a-button>
+      </a-empty>
+
+      <template v-else>
+        <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+          <a-button type="primary" @click="addFakedns">
+            <template #icon><PlusOutlined /></template>
+            {{ t('pages.xray.fakedns.add') }}
+          </a-button>
+          <a-table
+            :columns="fakednsColumns"
+            :data-source="fakeDnsList"
+            :row-key="(r) => r.key"
+            :pagination="false"
+            size="small"
+            bordered
+          >
+            <template #bodyCell="{ column, record, index }">
+              <template v-if="column.key === 'action'">
+                <a-space :size="6">
+                  <span class="row-index">{{ index + 1 }}</span>
+                  <a-button shape="circle" size="small" danger @click="deleteFakedns(index)">
+                    <DeleteOutlined />
+                  </a-button>
+                </a-space>
+              </template>
+              <template v-else-if="column.key === 'ipPool'">
+                <a-input
+                  :value="record.ipPool"
+                  size="small"
+                  @change="(e) => updateFakednsField(index, 'ipPool', e.target.value)"
+                />
+              </template>
+              <template v-else-if="column.key === 'poolSize'">
+                <a-input-number
+                  :value="record.poolSize"
+                  :min="1"
+                  size="small"
+                  @change="(v) => updateFakednsField(index, 'poolSize', v)"
+                />
+              </template>
+            </template>
+          </a-table>
+        </a-space>
+      </template>
+    </a-collapse-panel>
+  </a-collapse>
+
+  <DnsServerModal
+    v-model:open="serverModalOpen"
+    :server="editingServer"
+    :is-edit="editingIndex != null"
+    @confirm="onServerConfirm"
+  />
+</template>
+
+<style scoped>
+.row-index {
+  font-weight: 500;
+  opacity: 0.7;
+}
+.muted { opacity: 0.7; word-break: break-all; }
+.danger { color: #ff4d4f; }
+</style>

+ 379 - 0
frontend/src/pages/xray/NordModal.vue

@@ -0,0 +1,379 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { LoginOutlined, SaveOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import { HttpUtil } from '@/utils';
+
+// NordVPN provisioning modal — mirrors the legacy nord_modal.
+//
+// Login routes:
+//   • access token (NordVPN account) → /panel/xray/nord/reg
+//   • manual private key (existing wireguard key from NordLynx) →
+//     /panel/xray/nord/setKey
+// Once authenticated, the country / city / server selectors fetch
+// from /panel/xray/nord/{countries,servers}, and the user can stage
+// a wireguard outbound (tag `nord-<hostname>`) for the parent's
+// outbound list.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  templateSettings: { type: Object, default: null },
+});
+
+const emit = defineEmits([
+  'update:open',
+  'add-outbound',
+  'reset-outbound',
+  'remove-outbound',
+  // Routing rules referencing the deleted nord-* outbound need the
+  // parent to clean them up — we emit, the parent purges.
+  'remove-routing-rules',
+]);
+
+const loading = ref(false);
+const nordData = ref(null);
+const token = ref('');
+const manualKey = ref('');
+
+const countries = ref([]);
+const cities = ref([]);
+const servers = ref([]);
+const countryId = ref(null);
+const cityId = ref(null);
+const serverId = ref(null);
+
+const nordOutboundIndex = computed(() => {
+  const list = props.templateSettings?.outbounds;
+  if (!list) return -1;
+  return list.findIndex((o) => o?.tag?.startsWith?.('nord-'));
+});
+
+const filteredServers = computed(() => {
+  if (!cityId.value) return servers.value;
+  return servers.value.filter((s) => s.cityId === cityId.value);
+});
+
+watch(() => props.open, (next) => {
+  if (next) fetchData();
+});
+
+watch(() => filteredServers.value, (list) => {
+  // Auto-select the first server in the visible list (lowest load
+  // because servers were sorted ascending by load on fetch).
+  serverId.value = list.length > 0 ? list[0].id : null;
+});
+
+// === API actions ====================================================
+async function fetchData() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/nord/data');
+    if (msg?.success) {
+      nordData.value = msg.obj ? JSON.parse(msg.obj) : null;
+      if (nordData.value) await fetchCountries();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function login() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: token.value });
+    if (msg?.success) {
+      nordData.value = JSON.parse(msg.obj);
+      await fetchCountries();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function saveKey() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: manualKey.value });
+    if (msg?.success) {
+      nordData.value = JSON.parse(msg.obj);
+      await fetchCountries();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function logout() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/nord/del');
+    if (msg?.success) {
+      // Clean up the staged outbound + matching routing rules first
+      // so a re-login doesn't carry stale references.
+      emit('remove-outbound', nordOutboundIndex.value);
+      emit('remove-routing-rules', { prefix: 'nord-' });
+      nordData.value = null;
+      token.value = '';
+      manualKey.value = '';
+      countries.value = [];
+      cities.value = [];
+      servers.value = [];
+      countryId.value = null;
+      cityId.value = null;
+      serverId.value = null;
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fetchCountries() {
+  const msg = await HttpUtil.post('/panel/xray/nord/countries');
+  if (msg?.success) countries.value = JSON.parse(msg.obj);
+}
+
+async function fetchServers() {
+  if (!countryId.value) return;
+  loading.value = true;
+  servers.value = [];
+  cities.value = [];
+  serverId.value = null;
+  cityId.value = null;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: countryId.value });
+    if (!msg?.success) return;
+    const data = JSON.parse(msg.obj);
+    const locations = data.locations || [];
+    const locToCity = {};
+    const citiesMap = new Map();
+    for (const loc of locations) {
+      if (loc.country?.city) {
+        citiesMap.set(loc.country.city.id, loc.country.city);
+        locToCity[loc.id] = loc.country.city;
+      }
+    }
+    cities.value = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
+
+    servers.value = (data.servers || [])
+      .map((s) => {
+        const firstLocId = (s.location_ids || [])[0];
+        const city = locToCity[firstLocId];
+        return { ...s, cityId: city?.id || null, cityName: city?.name || 'Unknown' };
+      })
+      .sort((a, b) => a.load - b.load);
+
+    if (servers.value.length === 0) {
+      message.warning('No servers found for the selected country');
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+// === Outbound staging ==============================================
+// NordVPN exposes its WireGuard public key via a "technologies"
+// array entry with id 35; the legacy modal pulls the key from the
+// metadata field of that entry. Same here.
+function buildNordOutbound() {
+  const server = servers.value.find((s) => s.id === serverId.value);
+  if (!server) return null;
+  const tech = server.technologies?.find((t) => t.id === 35);
+  const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
+  if (!publicKey) {
+    message.error('Selected server does not advertise a NordLynx public key.');
+    return null;
+  }
+  return {
+    tag: `nord-${server.hostname}`,
+    protocol: 'wireguard',
+    settings: {
+      secretKey: nordData.value.private_key,
+      address: ['10.5.0.2/32'],
+      peers: [{ publicKey, endpoint: `${server.station}:51820` }],
+      noKernelTun: false,
+    },
+  };
+}
+
+function addOutbound() {
+  const ob = buildNordOutbound();
+  if (!ob) return;
+  emit('add-outbound', ob);
+  message.success('NordVPN outbound added');
+  close();
+}
+
+function resetOutbound() {
+  if (nordOutboundIndex.value === -1) return;
+  const ob = buildNordOutbound();
+  if (!ob) return;
+  // Tag rename across routing.rules is the parent's job — pass
+  // both old and new tag in the payload.
+  const oldTag = props.templateSettings.outbounds[nordOutboundIndex.value]?.tag;
+  emit('reset-outbound', {
+    index: nordOutboundIndex.value,
+    outbound: ob,
+    oldTag,
+    newTag: ob.tag,
+  });
+  message.success('NordVPN outbound updated');
+  close();
+}
+
+function close() { emit('update:open', false); }
+</script>
+
+<template>
+  <a-modal :open="open" title="NordVPN NordLynx" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
+    <!-- WARP / NordVPN provisioning forms keep technical wire labels in
+         English on purpose: they map directly to API field names users
+         look up in vendor docs. Only the primary action buttons +
+         dialog headers translate. -->
+    <!-- Not authenticated → tabbed login (token or manual key) -->
+    <template v-if="nordData == null">
+      <a-tabs default-active-key="token">
+        <a-tab-pane key="token" tab="Access token">
+          <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
+            <a-form-item label="Access token">
+              <a-input v-model:value="token" placeholder="Access token" />
+              <a-button type="primary" class="mt-10" :loading="loading" @click="login">
+                <template #icon>
+                  <LoginOutlined />
+                </template>
+                Login
+              </a-button>
+            </a-form-item>
+          </a-form>
+        </a-tab-pane>
+        <a-tab-pane key="key" tab="Private key">
+          <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
+            <a-form-item label="Private key">
+              <a-input v-model:value="manualKey" placeholder="Private key" />
+              <a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
+                <template #icon>
+                  <SaveOutlined />
+                </template>
+                Save
+              </a-button>
+            </a-form-item>
+          </a-form>
+        </a-tab-pane>
+      </a-tabs>
+    </template>
+
+    <!-- Authenticated → server picker + outbound controls -->
+    <template v-else>
+      <table class="nord-data-table">
+        <tbody>
+          <tr v-if="nordData.token" class="row-odd">
+            <td>Access token</td>
+            <td>{{ nordData.token }}</td>
+          </tr>
+          <tr>
+            <td>Private key</td>
+            <td>{{ nordData.private_key }}</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <a-button :loading="loading" type="primary" danger class="mt-8" @click="logout">Logout</a-button>
+
+      <a-divider class="zero-margin">Settings</a-divider>
+
+      <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-10">
+        <a-form-item label="Country">
+          <a-select v-model:value="countryId" show-search option-filter-prop="label" @change="fetchServers">
+            <a-select-option v-for="c in countries" :key="c.id" :value="c.id" :label="c.name">
+              {{ c.name }} ({{ c.code }})
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <a-form-item v-if="cities.length > 0" label="City">
+          <a-select v-model:value="cityId" show-search option-filter-prop="label">
+            <a-select-option :value="null" label="All cities">All cities</a-select-option>
+            <a-select-option v-for="c in cities" :key="c.id" :value="c.id" :label="c.name">{{ c.name
+            }}</a-select-option>
+          </a-select>
+        </a-form-item>
+
+        <a-form-item v-if="filteredServers.length > 0" label="Server">
+          <a-select v-model:value="serverId">
+            <a-select-option v-for="s in filteredServers" :key="s.id" :value="s.id">
+              {{ s.cityName }} - {{ s.name }} (load: {{ s.load }}%)
+            </a-select-option>
+          </a-select>
+        </a-form-item>
+      </a-form>
+
+      <a-divider class="my-10">Outbound status</a-divider>
+
+      <template v-if="nordOutboundIndex >= 0">
+        <a-tag color="green">Enabled</a-tag>
+        <a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
+          Reset
+        </a-button>
+      </template>
+      <template v-else>
+        <a-tag color="orange">Disabled</a-tag>
+        <a-button type="primary" class="ml-8" :disabled="!serverId" :loading="loading" @click="addOutbound">Add
+          outbound</a-button>
+      </template>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.nord-data-table {
+  margin: 5px 0;
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.nord-data-table td {
+  padding: 4px 8px;
+  word-break: break-all;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+
+.nord-data-table td:first-child {
+  font-family: inherit;
+  font-weight: 500;
+  white-space: nowrap;
+  width: 130px;
+}
+
+.row-odd {
+  background: rgba(0, 0, 0, 0.03);
+}
+
+:global(body.dark) .row-odd {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+.zero-margin {
+  margin: 0;
+}
+
+.mt-8 {
+  margin-top: 8px;
+}
+
+.mt-10 {
+  margin-top: 10px;
+}
+
+.mt-20 {
+  margin-top: 20px;
+}
+
+.my-10 {
+  margin: 10px 0;
+}
+
+.ml-8 {
+  margin-left: 8px;
+}
+</style>

+ 1007 - 0
frontend/src/pages/xray/OutboundFormModal.vue

@@ -0,0 +1,1007 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { message } from 'ant-design-vue';
+import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+
+import { Wireguard } from '@/utils';
+import {
+  Outbound,
+  Protocols,
+  SSMethods,
+  TLS_FLOW_CONTROL,
+  UTLS_FINGERPRINT,
+  ALPN_OPTION,
+  SNIFFING_OPTION,
+  USERS_SECURITY,
+  OutboundDomainStrategies,
+  WireguardDomainStrategy,
+  Address_Port_Strategy,
+  MODE_OPTION,
+  DNSRuleActions,
+} from '@/models/outbound.js';
+import FinalMaskForm from '@/components/FinalMaskForm.vue';
+
+const { t } = useI18n();
+
+// Structured outbound add/edit modal — mirrors the legacy
+// web/html/form/outbound.html. Covers every protocol + transport
+// combination the legacy panel exposes; the JSON tab still lets
+// power-users hand-edit fields the structured form doesn't surface
+// (reverse-sniffing, exotic outbound DNS rules, etc.).
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  outbound: { type: Object, default: null },
+  existingTags: { type: Array, default: () => [] },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const PROTOCOL_OPTIONS = Object.values(Protocols);
+const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT);
+const ALPN_OPTIONS = Object.values(ALPN_OPTION);
+const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
+const NETWORK_LABELS = {
+  tcp: 'TCP (RAW)',
+  kcp: 'mKCP',
+  ws: 'WebSocket',
+  grpc: 'gRPC',
+  httpupgrade: 'HTTPUpgrade',
+  xhttp: 'XHTTP',
+};
+
+// Reactive draft — Outbound instance built from the prop on open.
+// Intentionally shadows the prop name; the template reads the draft.
+// eslint-disable-next-line vue/no-dupe-keys
+const outbound = ref(null);
+const isEdit = ref(false);
+const activeKey = ref('1');
+const linkInput = ref('');
+
+// Advanced JSON editor — kept in sync with the parsed Outbound on tab
+// switch so users can copy/paste a full JSON config when the structured
+// form doesn't reach a field.
+const advancedJson = ref('');
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (props.outbound) {
+    isEdit.value = true;
+    outbound.value = Outbound.fromJson(props.outbound);
+  } else {
+    isEdit.value = false;
+    outbound.value = new Outbound();
+  }
+  activeKey.value = '1';
+  linkInput.value = '';
+  primeAdvancedJson();
+});
+
+watch(activeKey, (key) => {
+  if (key === '2') primeAdvancedJson();
+});
+
+function primeAdvancedJson() {
+  if (!outbound.value) { advancedJson.value = ''; return; }
+  try {
+    advancedJson.value = JSON.stringify(outbound.value.toJson(), null, 2);
+  } catch (_e) {
+    advancedJson.value = '';
+  }
+}
+
+function close() { emit('update:open', false); }
+
+function onProtocolChange(next) {
+  if (!outbound.value) return;
+  outbound.value.protocol = next;
+}
+
+function streamNetworkChange(next) {
+  if (!outbound.value?.stream) return;
+  outbound.value.stream.network = next;
+  if (!outbound.value.canEnableTls()) outbound.value.stream.security = 'none';
+}
+
+const duplicateTag = computed(() => {
+  if (!outbound.value?.tag) return false;
+  const myTag = outbound.value.tag.trim();
+  if (!myTag) return false;
+  if (isEdit.value && props.outbound?.tag === myTag) return false;
+  return (props.existingTags || []).includes(myTag);
+});
+
+const tagEmpty = computed(() => !outbound.value?.tag?.trim());
+
+const tagValidateStatus = computed(() => {
+  if (tagEmpty.value) return 'error';
+  if (duplicateTag.value) return 'warning';
+  return 'success';
+});
+
+const tagHelp = computed(() => {
+  if (tagEmpty.value) return 'Tag is required';
+  if (duplicateTag.value) return 'Tag already used by another outbound';
+  return '';
+});
+
+// ============== Submit ==============
+function onOk() {
+  if (!outbound.value) return;
+  if (!outbound.value.tag?.trim()) {
+    message.error(t('somethingWentWrong'));
+    return;
+  }
+  if (duplicateTag.value) {
+    message.error(t('somethingWentWrong'));
+    return;
+  }
+  // If user spent time in the JSON tab, prefer that body — round-trip
+  // it through Outbound.fromJson so the wire shape stays consistent.
+  if (activeKey.value === '2' && advancedJson.value.trim()) {
+    try {
+      const parsed = JSON.parse(advancedJson.value);
+      const built = Outbound.fromJson(parsed);
+      emit('confirm', built.toJson());
+      return;
+    } catch (e) {
+      message.error(`JSON: ${e.message}`);
+      return;
+    }
+  }
+  emit('confirm', outbound.value.toJson());
+}
+
+// ============== Link → outbound ==============
+// Mirrors the legacy convertLink: dispatches into Outbound.fromLink,
+// which handles vmess:// (base64 JSON), vless://, trojan://, ss://
+// (param-link form), and hysteria(2)://. Anything else returns null
+// from the model and we surface "Wrong Link!" the same as legacy.
+function convertLink() {
+  const link = linkInput.value.trim();
+  if (!link) return;
+  try {
+    const next = Outbound.fromLink(link);
+    if (!next) {
+      message.error('Wrong Link!');
+      return;
+    }
+    outbound.value = next;
+    linkInput.value = '';
+    message.success('Link imported successfully...');
+    activeKey.value = '1';
+  } catch (e) {
+    message.error(`Link parse: ${e.message}`);
+  }
+}
+
+const title = computed(() =>
+  isEdit.value
+    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
+    : `+ ${t('pages.xray.Outbounds')}`,
+);
+const okText = computed(() =>
+  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+);
+
+// Helper getters / shortcuts used by the template.
+const proto = computed(() => outbound.value?.protocol);
+const isVMess = computed(() => proto.value === Protocols.VMess);
+const isVLESS = computed(() => proto.value === Protocols.VLESS);
+const isVMessOrVLess = computed(() => isVMess.value || isVLESS.value);
+const isTrojan = computed(() => proto.value === Protocols.Trojan);
+const isShadowsocks = computed(() => proto.value === Protocols.Shadowsocks);
+const isFreedom = computed(() => proto.value === Protocols.Freedom);
+const isBlackhole = computed(() => proto.value === Protocols.Blackhole);
+const isDNS = computed(() => proto.value === Protocols.DNS);
+const isWireguard = computed(() => proto.value === Protocols.Wireguard);
+const isHysteria = computed(() => proto.value === Protocols.Hysteria);
+
+function regenerateWgKeys() {
+  if (!outbound.value?.settings) return;
+  const pair = Wireguard.generateKeypair();
+  outbound.value.settings.secretKey = pair.privateKey;
+  outbound.value.settings.pubKey = pair.publicKey;
+}
+</script>
+
+<template>
+  <a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="780px"
+    @ok="onOk" @cancel="close">
+    <a-tabs v-if="outbound" v-model:active-key="activeKey">
+      <!-- ============================== FORM ============================== -->
+      <a-tab-pane key="1" :tab="t('pages.xray.basicTemplate')">
+        <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+          <!-- Protocol -->
+          <a-form-item :label="t('protocol')">
+            <a-select :value="proto" @change="onProtocolChange">
+              <a-select-option v-for="p in PROTOCOL_OPTIONS" :key="p" :value="p">{{ p }}</a-select-option>
+            </a-select>
+          </a-form-item>
+
+          <!-- Tag -->
+          <a-form-item label="Tag" :validate-status="tagValidateStatus" :help="tagHelp" has-feedback>
+            <a-input v-model:value="outbound.tag" placeholder="unique-tag" />
+          </a-form-item>
+
+          <!-- Send through -->
+          <a-form-item label="Send through">
+            <a-input v-model:value="outbound.sendThrough" placeholder="local IP" />
+          </a-form-item>
+
+          <!-- ============== Freedom ============== -->
+          <template v-if="isFreedom">
+            <a-form-item label="Strategy">
+              <a-select v-model:value="outbound.settings.domainStrategy">
+                <a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Redirect">
+              <a-input v-model:value="outbound.settings.redirect" />
+            </a-form-item>
+
+            <a-form-item label="Fragment">
+              <a-switch :checked="!!outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0"
+                @change="(checked) => outbound.settings.fragment = checked ? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' } : {}" />
+            </a-form-item>
+            <template v-if="outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0">
+              <a-form-item label="Packets">
+                <a-select v-model:value="outbound.settings.fragment.packets">
+                  <a-select-option v-for="p in ['1-3', 'tlshello']" :key="p" :value="p">{{ p }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Length">
+                <a-input v-model:value="outbound.settings.fragment.length" placeholder="100-200" />
+              </a-form-item>
+              <a-form-item label="Interval">
+                <a-input v-model:value="outbound.settings.fragment.interval" placeholder="10-20" />
+              </a-form-item>
+              <a-form-item label="Max Split">
+                <a-input v-model:value="outbound.settings.fragment.maxSplit" placeholder="300-400" />
+              </a-form-item>
+            </template>
+
+            <a-form-item label="Noises">
+              <a-switch :checked="(outbound.settings.noises || []).length > 0"
+                @change="(checked) => outbound.settings.noises = checked ? [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }] : []" />
+              <a-button v-if="outbound.settings.noises && outbound.settings.noises.length > 0" size="small"
+                type="primary" class="ml-8"
+                @click="outbound.settings.noises.push({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
+              <div class="item-heading">
+                <span>Noise {{ index + 1 }}</span>
+                <DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
+                  @click="outbound.settings.noises.splice(index, 1)" />
+              </div>
+              <a-form-item label="Type">
+                <a-select v-model:value="noise.type">
+                  <a-select-option v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
+                  }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Packet">
+                <a-input v-model:value="noise.packet" />
+              </a-form-item>
+              <a-form-item label="Delay (ms)">
+                <a-input v-model:value="noise.delay" />
+              </a-form-item>
+              <a-form-item label="Apply to">
+                <a-select v-model:value="noise.applyTo">
+                  <a-select-option v-for="x in ['ip', 'ipv4', 'ipv6']" :key="x" :value="x">{{ x }}</a-select-option>
+                </a-select>
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== Blackhole ============== -->
+          <template v-if="isBlackhole">
+            <a-form-item label="Response Type">
+              <a-select v-model:value="outbound.settings.type">
+                <a-select-option v-for="x in ['', 'none', 'http']" :key="x" :value="x">{{ x || '(empty)'
+                }}</a-select-option>
+              </a-select>
+            </a-form-item>
+          </template>
+
+          <!-- ============== DNS ============== -->
+          <template v-if="isDNS">
+            <a-form-item :label="t('pages.inbounds.network')">
+              <a-select v-model:value="outbound.settings.network">
+                <a-select-option v-for="x in ['udp', 'tcp']" :key="x" :value="x">{{ x }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Rules">
+              <a-button size="small" type="primary"
+                @click="outbound.settings.rules.push({ action: 'direct', qtype: '', domain: '' })">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
+              <div class="item-heading">
+                <span>Rule {{ index + 1 }}</span>
+                <DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
+              </div>
+              <a-form-item label="Action">
+                <a-select v-model:value="rule.action">
+                  <a-select-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="QType">
+                <a-input v-model:value="rule.qtype" placeholder="1,3,23-24" />
+              </a-form-item>
+              <a-form-item :label="t('domainName')">
+                <a-input v-model:value="rule.domain" placeholder="domain:example.com" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== WireGuard ============== -->
+          <template v-if="isWireguard">
+            <a-form-item :label="t('pages.inbounds.address')">
+              <a-input v-model:value="outbound.settings.address" />
+            </a-form-item>
+            <a-form-item>
+              <template #label>
+                {{ t('pages.inbounds.privatekey') }}
+                <SyncOutlined class="random-icon" @click="regenerateWgKeys" />
+              </template>
+              <a-input v-model:value="outbound.settings.secretKey" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.publicKey')">
+              <a-input :value="outbound.settings.pubKey" disabled />
+            </a-form-item>
+            <a-form-item label="Domain strategy">
+              <a-select v-model:value="outbound.settings.domainStrategy">
+                <a-select-option v-for="x in ['', ...WireguardDomainStrategy]" :key="x || '__'" :value="x">
+                  {{ x || `(${t('none')})` }}
+                </a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="MTU">
+              <a-input-number v-model:value="outbound.settings.mtu" :min="0" />
+            </a-form-item>
+            <a-form-item label="Workers">
+              <a-input-number v-model:value="outbound.settings.workers" :min="0" />
+            </a-form-item>
+            <a-form-item label="No-kernel TUN">
+              <a-switch v-model:checked="outbound.settings.noKernelTun" />
+            </a-form-item>
+            <a-form-item label="Reserved">
+              <a-input v-model:value="outbound.settings.reserved" />
+            </a-form-item>
+            <a-form-item label="Peers">
+              <a-button size="small" type="primary"
+                @click="outbound.settings.peers.push({ endpoint: '', publicKey: '', psk: '', allowedIPs: [''], keepAlive: 0 })">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+            </a-form-item>
+            <template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
+              <div class="item-heading">
+                <span>Peer {{ index + 1 }}</span>
+                <DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
+                  @click="outbound.settings.peers.splice(index, 1)" />
+              </div>
+              <a-form-item label="Endpoint">
+                <a-input v-model:value="peer.endpoint" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.publicKey')">
+                <a-input v-model:value="peer.publicKey" />
+              </a-form-item>
+              <a-form-item label="PSK">
+                <a-input v-model:value="peer.psk" />
+              </a-form-item>
+              <a-form-item label="Allowed IPs">
+                <template v-for="(_, idx) in peer.allowedIPs" :key="idx">
+                  <a-input v-model:value="peer.allowedIPs[idx]" :style="{ marginBottom: '4px' }">
+                    <template v-if="peer.allowedIPs.length > 1" #addonAfter>
+                      <MinusOutlined @click="peer.allowedIPs.splice(idx, 1)" />
+                    </template>
+                  </a-input>
+                </template>
+                <a-button size="small" @click="peer.allowedIPs.push('')">
+                  <template #icon>
+                    <PlusOutlined />
+                  </template>
+                </a-button>
+              </a-form-item>
+              <a-form-item label="Keep alive">
+                <a-input-number v-model:value="peer.keepAlive" :min="0" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== Address + Port (most protocols) ============== -->
+          <template v-if="outbound.hasAddressPort()">
+            <a-form-item :label="t('pages.inbounds.address')">
+              <a-input v-model:value="outbound.settings.address" />
+            </a-form-item>
+            <a-form-item :label="t('pages.inbounds.port')">
+              <a-input-number v-model:value="outbound.settings.port" :min="1" :max="65535" />
+            </a-form-item>
+          </template>
+
+          <!-- ============== VMess / VLess user ============== -->
+          <template v-if="isVMessOrVLess">
+            <a-form-item label="ID">
+              <a-input v-model:value="outbound.settings.id" />
+            </a-form-item>
+            <a-form-item v-if="isVMess" :label="t('security')">
+              <a-select v-model:value="outbound.settings.security">
+                <a-select-option v-for="s in SECURITY_OPTIONS" :key="s" :value="s">{{ s }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item v-if="isVLESS" :label="t('encryption')">
+              <a-input v-model:value="outbound.settings.encryption" />
+            </a-form-item>
+            <a-form-item v-if="isVLESS" label="Reverse tag">
+              <a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
+            </a-form-item>
+
+            <!-- Reverse-Sniffing — surfaced only when a reverse tag is set,
+                 mirroring the legacy form. Defaults populated by the model
+                 so the toggle/checkboxes always have a backing field. -->
+            <template v-if="isVLESS && outbound.settings.reverseTag">
+              <a-form-item label="Reverse Sniffing">
+                <a-switch v-model:checked="outbound.settings.reverseSniffing.enabled" />
+              </a-form-item>
+              <template v-if="outbound.settings.reverseSniffing.enabled">
+                <!-- Align the checkbox row with the input fields above —
+                     same span as wrapper-col (14), offset by label-col (8)
+                     so the row starts where Reverse Tag's input starts. -->
+                <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }">
+                  <a-checkbox-group v-model:value="outbound.settings.reverseSniffing.destOverride"
+                    class="sniffing-options">
+                    <a-checkbox v-for="(value, label) in SNIFFING_OPTION" :key="value" :value="value">{{ label
+                    }}</a-checkbox>
+                  </a-checkbox-group>
+                </a-form-item>
+                <a-form-item label="Metadata Only">
+                  <a-switch v-model:checked="outbound.settings.reverseSniffing.metadataOnly" />
+                </a-form-item>
+                <a-form-item label="Route Only">
+                  <a-switch v-model:checked="outbound.settings.reverseSniffing.routeOnly" />
+                </a-form-item>
+                <a-form-item label="IPs Excluded">
+                  <a-select v-model:value="outbound.settings.reverseSniffing.ipsExcluded" mode="tags"
+                    :token-separators="[',']" placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
+                </a-form-item>
+                <a-form-item label="Domains Excluded">
+                  <a-select v-model:value="outbound.settings.reverseSniffing.domainsExcluded" mode="tags"
+                    :token-separators="[',']" placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
+                </a-form-item>
+              </template>
+            </template>
+            <a-form-item v-if="outbound.canEnableTlsFlow()" label="Flow">
+              <a-select v-model:value="outbound.settings.flow">
+                <a-select-option value="">{{ t('none') }}</a-select-option>
+                <a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+              </a-select>
+            </a-form-item>
+          </template>
+
+          <!-- ============== Trojan / Shadowsocks ============== -->
+          <template v-if="isTrojan || isShadowsocks">
+            <a-form-item :label="t('password')">
+              <a-input v-model:value="outbound.settings.password" />
+            </a-form-item>
+          </template>
+          <template v-if="isShadowsocks">
+            <a-form-item :label="t('encryption')">
+              <a-select v-model:value="outbound.settings.method">
+                <a-select-option v-for="(m, k) in SSMethods" :key="m" :value="m">{{ k }}</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="UDP over TCP">
+              <a-switch v-model:checked="outbound.settings.uot" />
+            </a-form-item>
+            <a-form-item label="UoT version">
+              <a-input-number v-model:value="outbound.settings.UoTVersion" :min="1" :max="2" />
+            </a-form-item>
+          </template>
+
+          <!-- ============== SOCKS / HTTP ============== -->
+          <template v-if="outbound.hasUsername()">
+            <a-form-item :label="t('username')">
+              <a-input v-model:value="outbound.settings.user" />
+            </a-form-item>
+            <a-form-item :label="t('password')">
+              <a-input v-model:value="outbound.settings.pass" />
+            </a-form-item>
+          </template>
+
+          <!-- ============== Hysteria ============== -->
+          <template v-if="isHysteria">
+            <a-form-item label="Version">
+              <a-input-number :value="outbound.settings.version || 2" :min="2" :max="2" disabled />
+            </a-form-item>
+          </template>
+
+          <!-- ============== Stream settings ============== -->
+          <template v-if="outbound.canEnableStream()">
+            <a-form-item :label="t('transmission')">
+              <a-select :value="outbound.stream.network" @change="streamNetworkChange">
+                <a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
+                  :value="net">
+                  {{ NETWORK_LABELS[net] || net }}
+                </a-select-option>
+              </a-select>
+            </a-form-item>
+
+            <!-- TCP -->
+            <template v-if="outbound.stream.network === 'tcp'">
+              <a-form-item :label="`HTTP ${t('camouflage')}`">
+                <a-switch :checked="outbound.stream.tcp.type === 'http'"
+                  @change="(checked) => outbound.stream.tcp.type = checked ? 'http' : 'none'" />
+              </a-form-item>
+              <template v-if="outbound.stream.tcp.type === 'http'">
+                <a-form-item :label="t('host')">
+                  <a-input v-model:value="outbound.stream.tcp.host" />
+                </a-form-item>
+                <a-form-item :label="t('path')">
+                  <a-input v-model:value="outbound.stream.tcp.path" />
+                </a-form-item>
+              </template>
+            </template>
+
+            <!-- KCP -->
+            <template v-if="outbound.stream.network === 'kcp'">
+              <a-form-item label="MTU">
+                <a-input-number v-model:value="outbound.stream.kcp.mtu" :min="0" />
+              </a-form-item>
+              <a-form-item label="TTI (ms)">
+                <a-input-number v-model:value="outbound.stream.kcp.tti" :min="0" />
+              </a-form-item>
+              <a-form-item label="Uplink (MB/s)">
+                <a-input-number v-model:value="outbound.stream.kcp.upCap" :min="0" />
+              </a-form-item>
+              <a-form-item label="Downlink (MB/s)">
+                <a-input-number v-model:value="outbound.stream.kcp.downCap" :min="0" />
+              </a-form-item>
+              <a-form-item label="CWND multiplier">
+                <a-input-number v-model:value="outbound.stream.kcp.cwndMultiplier" :min="1" />
+              </a-form-item>
+              <a-form-item label="Max sending window">
+                <a-input-number v-model:value="outbound.stream.kcp.maxSendingWindow" :min="0" />
+              </a-form-item>
+            </template>
+
+            <!-- WebSocket -->
+            <template v-if="outbound.stream.network === 'ws'">
+              <a-form-item :label="t('host')">
+                <a-input v-model:value="outbound.stream.ws.host" />
+              </a-form-item>
+              <a-form-item :label="t('path')">
+                <a-input v-model:value="outbound.stream.ws.path" />
+              </a-form-item>
+              <a-form-item label="Heartbeat (s)">
+                <a-input-number v-model:value="outbound.stream.ws.heartbeatPeriod" :min="0" />
+              </a-form-item>
+            </template>
+
+            <!-- gRPC -->
+            <template v-if="outbound.stream.network === 'grpc'">
+              <a-form-item label="Service name">
+                <a-input v-model:value="outbound.stream.grpc.serviceName" />
+              </a-form-item>
+              <a-form-item label="Authority">
+                <a-input v-model:value="outbound.stream.grpc.authority" />
+              </a-form-item>
+              <a-form-item label="Multi mode">
+                <a-switch v-model:checked="outbound.stream.grpc.multiMode" />
+              </a-form-item>
+            </template>
+
+            <!-- HTTPUpgrade -->
+            <template v-if="outbound.stream.network === 'httpupgrade'">
+              <a-form-item :label="t('host')">
+                <a-input v-model:value="outbound.stream.httpupgrade.host" />
+              </a-form-item>
+              <a-form-item :label="t('path')">
+                <a-input v-model:value="outbound.stream.httpupgrade.path" />
+              </a-form-item>
+            </template>
+
+            <!-- XHTTP — full parity with legacy outbound form. The model
+                 already carries every field below; we just surface them. -->
+            <template v-if="outbound.stream.network === 'xhttp'">
+              <a-form-item :label="t('host')">
+                <a-input v-model:value="outbound.stream.xhttp.host" />
+              </a-form-item>
+              <a-form-item :label="t('path')">
+                <a-input v-model:value="outbound.stream.xhttp.path" />
+              </a-form-item>
+
+              <a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
+                <a-button size="small" @click="outbound.stream.xhttp.addHeader('', '')">
+                  <template #icon>
+                    <PlusOutlined />
+                  </template>
+                </a-button>
+              </a-form-item>
+              <a-form-item :wrapper-col="{ span: 24 }">
+                <a-input-group v-for="(header, idx) in outbound.stream.xhttp.headers" :key="idx" compact class="mb-8">
+                  <a-input v-model:value="header.name" :style="{ width: '45%' }" placeholder="Name">
+                    <template #addonBefore>{{ idx + 1 }}</template>
+                  </a-input>
+                  <a-input v-model:value="header.value" :style="{ width: '45%' }" placeholder="Value" />
+                  <a-button @click="outbound.stream.xhttp.removeHeader(idx)">
+                    <template #icon>
+                      <MinusOutlined />
+                    </template>
+                  </a-button>
+                </a-input-group>
+              </a-form-item>
+
+              <a-form-item label="Mode">
+                <a-select v-model:value="outbound.stream.xhttp.mode">
+                  <a-select-option v-for="m in Object.values(MODE_OPTION)" :key="m" :value="m">{{ m }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Max Upload Size (Byte)">
+                <a-input v-model:value="outbound.stream.xhttp.scMaxEachPostBytes" />
+              </a-form-item>
+              <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Min Upload Interval (Ms)">
+                <a-input v-model:value="outbound.stream.xhttp.scMinPostsIntervalMs" />
+              </a-form-item>
+
+              <a-form-item label="Padding Bytes">
+                <a-input v-model:value="outbound.stream.xhttp.xPaddingBytes" />
+              </a-form-item>
+              <a-form-item label="Padding Obfs Mode">
+                <a-switch v-model:checked="outbound.stream.xhttp.xPaddingObfsMode" />
+              </a-form-item>
+              <template v-if="outbound.stream.xhttp.xPaddingObfsMode">
+                <a-form-item label="Padding Key">
+                  <a-input v-model:value="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding" />
+                </a-form-item>
+                <a-form-item label="Padding Header">
+                  <a-input v-model:value="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding" />
+                </a-form-item>
+                <a-form-item label="Padding Placement">
+                  <a-select v-model:value="outbound.stream.xhttp.xPaddingPlacement">
+                    <a-select-option value="">Default (queryInHeader)</a-select-option>
+                    <a-select-option value="queryInHeader">queryInHeader</a-select-option>
+                    <a-select-option value="header">header</a-select-option>
+                    <a-select-option value="cookie">cookie</a-select-option>
+                    <a-select-option value="query">query</a-select-option>
+                  </a-select>
+                </a-form-item>
+                <a-form-item label="Padding Method">
+                  <a-select v-model:value="outbound.stream.xhttp.xPaddingMethod">
+                    <a-select-option value="">Default (repeat-x)</a-select-option>
+                    <a-select-option value="repeat-x">repeat-x</a-select-option>
+                    <a-select-option value="tokenish">tokenish</a-select-option>
+                  </a-select>
+                </a-form-item>
+              </template>
+
+              <a-form-item label="Uplink HTTP Method">
+                <a-select v-model:value="outbound.stream.xhttp.uplinkHTTPMethod">
+                  <a-select-option value="">Default (POST)</a-select-option>
+                  <a-select-option value="POST">POST</a-select-option>
+                  <a-select-option value="PUT">PUT</a-select-option>
+                  <a-select-option value="GET" :disabled="outbound.stream.xhttp.mode !== 'packet-up'">GET (packet-up
+                    only)</a-select-option>
+                </a-select>
+              </a-form-item>
+
+              <a-form-item label="Session Placement">
+                <a-select v-model:value="outbound.stream.xhttp.sessionPlacement">
+                  <a-select-option value="">Default (path)</a-select-option>
+                  <a-select-option value="path">path</a-select-option>
+                  <a-select-option value="header">header</a-select-option>
+                  <a-select-option value="cookie">cookie</a-select-option>
+                  <a-select-option value="query">query</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item
+                v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'"
+                label="Session Key">
+                <a-input v-model:value="outbound.stream.xhttp.sessionKey" placeholder="x_session" />
+              </a-form-item>
+
+              <a-form-item label="Sequence Placement">
+                <a-select v-model:value="outbound.stream.xhttp.seqPlacement">
+                  <a-select-option value="">Default (path)</a-select-option>
+                  <a-select-option value="path">path</a-select-option>
+                  <a-select-option value="header">header</a-select-option>
+                  <a-select-option value="cookie">cookie</a-select-option>
+                  <a-select-option value="query">query</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'"
+                label="Sequence Key">
+                <a-input v-model:value="outbound.stream.xhttp.seqKey" placeholder="x_seq" />
+              </a-form-item>
+
+              <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
+                <a-select v-model:value="outbound.stream.xhttp.uplinkDataPlacement">
+                  <a-select-option value="">Default (body)</a-select-option>
+                  <a-select-option value="body">body</a-select-option>
+                  <a-select-option value="header">header</a-select-option>
+                  <a-select-option value="cookie">cookie</a-select-option>
+                  <a-select-option value="query">query</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
+                && outbound.stream.xhttp.uplinkDataPlacement
+                && outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Data Key">
+                <a-input v-model:value="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
+              </a-form-item>
+              <a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
+                && outbound.stream.xhttp.uplinkDataPlacement
+                && outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Chunk Size">
+                <a-input-number v-model:value="outbound.stream.xhttp.uplinkChunkSize" :min="0"
+                  placeholder="0 (unlimited)" />
+              </a-form-item>
+
+              <a-form-item
+                v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'"
+                label="No gRPC Header">
+                <a-switch v-model:checked="outbound.stream.xhttp.noGRPCHeader" />
+              </a-form-item>
+
+              <a-form-item label="XMUX">
+                <a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
+              </a-form-item>
+              <template v-if="outbound.stream.xhttp.enableXmux">
+                <a-form-item v-if="!outbound.stream.xhttp.xmux.maxConnections" label="Max Concurrency">
+                  <a-input v-model:value="outbound.stream.xhttp.xmux.maxConcurrency" />
+                </a-form-item>
+                <a-form-item v-if="!outbound.stream.xhttp.xmux.maxConcurrency" label="Max Connections">
+                  <a-input v-model:value="outbound.stream.xhttp.xmux.maxConnections" />
+                </a-form-item>
+                <a-form-item label="Max Reuse Times">
+                  <a-input v-model:value="outbound.stream.xhttp.xmux.cMaxReuseTimes" />
+                </a-form-item>
+                <a-form-item label="Max Request Times">
+                  <a-input v-model:value="outbound.stream.xhttp.xmux.hMaxRequestTimes" />
+                </a-form-item>
+                <a-form-item label="Max Reusable Secs">
+                  <a-input v-model:value="outbound.stream.xhttp.xmux.hMaxReusableSecs" />
+                </a-form-item>
+                <a-form-item label="Keep Alive Period">
+                  <a-input-number v-model:value="outbound.stream.xhttp.xmux.hKeepAlivePeriod" :min="0" />
+                </a-form-item>
+              </template>
+            </template>
+
+            <!-- Hysteria transport -->
+            <template v-if="outbound.stream.network === 'hysteria'">
+              <a-form-item label="Auth password">
+                <a-input v-model:value="outbound.stream.hysteria.auth" />
+              </a-form-item>
+              <a-form-item label="Congestion">
+                <a-select v-model:value="outbound.stream.hysteria.congestion">
+                  <a-select-option value="">BBR (auto)</a-select-option>
+                  <a-select-option value="brutal">Brutal</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Upload">
+                <a-input v-model:value="outbound.stream.hysteria.up" placeholder="100 mbps" />
+              </a-form-item>
+              <a-form-item label="Download">
+                <a-input v-model:value="outbound.stream.hysteria.down" placeholder="100 mbps" />
+              </a-form-item>
+              <a-form-item label="UDP hop port">
+                <a-input v-model:value="outbound.stream.hysteria.udphopPort" placeholder="1145-1919" />
+              </a-form-item>
+              <a-form-item label="Max idle (s)">
+                <a-input-number v-model:value="outbound.stream.hysteria.maxIdleTimeout" :min="4" :max="120" />
+              </a-form-item>
+              <a-form-item label="Keep alive (s)">
+                <a-input-number v-model:value="outbound.stream.hysteria.keepAlivePeriod" :min="2" :max="60" />
+              </a-form-item>
+              <a-form-item label="Disable Path MTU">
+                <a-switch v-model:checked="outbound.stream.hysteria.disablePathMTUDiscovery" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== TLS / Reality ============== -->
+          <template v-if="outbound.canEnableTls()">
+            <a-form-item :label="t('security')">
+              <a-radio-group v-model:value="outbound.stream.security" button-style="solid">
+                <a-radio-button value="none">{{ t('none') }}</a-radio-button>
+                <a-radio-button value="tls">TLS</a-radio-button>
+                <a-radio-button v-if="outbound.canEnableReality()" value="reality">Reality</a-radio-button>
+              </a-radio-group>
+            </a-form-item>
+
+            <template v-if="outbound.stream.isTls">
+              <a-form-item label="SNI">
+                <a-input v-model:value="outbound.stream.tls.serverName" placeholder="server name" />
+              </a-form-item>
+              <a-form-item label="uTLS">
+                <a-select v-model:value="outbound.stream.tls.fingerprint">
+                  <a-select-option value="">{{ t('none') }}</a-select-option>
+                  <a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="ALPN">
+                <a-select v-model:value="outbound.stream.tls.alpn" mode="multiple">
+                  <a-select-option v-for="alpn in ALPN_OPTIONS" :key="alpn" :value="alpn">{{ alpn }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="ECH">
+                <a-input v-model:value="outbound.stream.tls.echConfigList" />
+              </a-form-item>
+              <a-form-item label="Verify peer name">
+                <a-input v-model:value="outbound.stream.tls.verifyPeerCertByName" placeholder="cloudflare-dns.com" />
+              </a-form-item>
+              <a-form-item label="Pinned SHA256">
+                <a-input v-model:value="outbound.stream.tls.pinnedPeerCertSha256" placeholder="base64 SHA256" />
+              </a-form-item>
+            </template>
+
+            <template v-if="outbound.stream.isReality">
+              <a-form-item label="SNI">
+                <a-input v-model:value="outbound.stream.reality.serverName" />
+              </a-form-item>
+              <a-form-item label="uTLS">
+                <a-select v-model:value="outbound.stream.reality.fingerprint">
+                  <a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Short ID">
+                <a-input v-model:value="outbound.stream.reality.shortId" />
+              </a-form-item>
+              <a-form-item label="SpiderX">
+                <a-input v-model:value="outbound.stream.reality.spiderX" />
+              </a-form-item>
+              <a-form-item :label="t('pages.inbounds.publicKey')">
+                <a-textarea v-model:value="outbound.stream.reality.publicKey" :auto-size="{ minRows: 2 }" />
+              </a-form-item>
+              <a-form-item label="mldsa65 verify">
+                <a-textarea v-model:value="outbound.stream.reality.mldsa65Verify" :auto-size="{ minRows: 2 }" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== sockopt ============== -->
+          <template v-if="outbound.stream">
+            <a-form-item label="Sockopts">
+              <a-switch v-model:checked="outbound.stream.sockoptSwitch" />
+            </a-form-item>
+            <template v-if="outbound.stream.sockoptSwitch">
+              <a-form-item label="Dialer proxy">
+                <a-input v-model:value="outbound.stream.sockopt.dialerProxy" />
+              </a-form-item>
+              <a-form-item label="Address+Port strategy">
+                <a-select v-model:value="outbound.stream.sockopt.addressPortStrategy">
+                  <a-select-option v-for="key in Object.values(Address_Port_Strategy)" :key="key" :value="key">
+                    {{ key }}
+                  </a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Keep alive interval">
+                <a-input-number v-model:value="outbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
+              </a-form-item>
+              <a-form-item label="TCP Fast Open">
+                <a-switch v-model:checked="outbound.stream.sockopt.tcpFastOpen" />
+              </a-form-item>
+              <a-form-item label="Multipath TCP">
+                <a-switch v-model:checked="outbound.stream.sockopt.tcpMptcp" />
+              </a-form-item>
+              <a-form-item label="Penetrate">
+                <a-switch v-model:checked="outbound.stream.sockopt.penetrate" />
+              </a-form-item>
+            </template>
+          </template>
+
+          <!-- ============== Mux ============== -->
+          <template v-if="outbound.canEnableMux()">
+            <a-form-item :label="t('pages.settings.mux')">
+              <a-switch v-model:checked="outbound.mux.enabled" />
+            </a-form-item>
+            <template v-if="outbound.mux.enabled">
+              <a-form-item label="Concurrency">
+                <a-input-number v-model:value="outbound.mux.concurrency" :min="-1" :max="1024" />
+              </a-form-item>
+              <a-form-item label="xudp concurrency">
+                <a-input-number v-model:value="outbound.mux.xudpConcurrency" :min="-1" :max="1024" />
+              </a-form-item>
+              <a-form-item label="xudp UDP 443">
+                <a-select v-model:value="outbound.mux.xudpProxyUDP443">
+                  <a-select-option v-for="x in ['reject', 'allow', 'skip']" :key="x" :value="x">{{ x
+                  }}</a-select-option>
+                </a-select>
+              </a-form-item>
+            </template>
+          </template>
+        </a-form>
+
+        <!-- ============== FinalMask (TCP/UDP masks + QUIC params) ============== -->
+        <!-- Gated by canEnableStream() so TCP masks don't leak into
+             Freedom / Blackhole / DNS / Socks / HTTP / Wireguard outbounds
+             (they don't have a stream config at all). Matches legacy. -->
+        <FinalMaskForm
+          v-if="outbound.stream && outbound.canEnableStream()"
+          :stream="outbound.stream"
+          :protocol="proto"
+        />
+      </a-tab-pane>
+
+      <!-- ============================== JSON ============================== -->
+      <a-tab-pane key="2" tab="JSON">
+        <a-space direction="vertical" :size="10" :style="{ width: '100%', marginTop: '10px' }">
+          <a-input-search v-model:value="linkInput" placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
+            @search="convertLink">
+            <template #enterButton>
+              <a-button>Convert</a-button>
+            </template>
+          </a-input-search>
+          <a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
+            class="json-editor" />
+        </a-space>
+      </a-tab-pane>
+    </a-tabs>
+  </a-modal>
+</template>
+
+<style scoped>
+.random-icon {
+  cursor: pointer;
+  color: var(--ant-primary-color, #1890ff);
+  margin-left: 4px;
+}
+
+.danger-icon {
+  cursor: pointer;
+  color: #ff4d4f;
+  margin-left: 8px;
+}
+
+.ml-8 {
+  margin-left: 8px;
+}
+
+.mb-8 {
+  margin-bottom: 8px;
+}
+
+.section-heading {
+  font-weight: 500;
+  margin: 12px 0 6px;
+  opacity: 0.85;
+}
+
+.item-heading {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 500;
+  margin: 8px 0 4px;
+  opacity: 0.85;
+}
+
+.json-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+
+/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
+ * inline-block, but inside a narrow form wrapper they can wrap
+ * inconsistently. Force a clean horizontal row with even gaps. */
+.sniffing-options {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px 16px;
+}
+
+.sniffing-options :deep(.ant-checkbox-wrapper) {
+  margin-inline-start: 0;
+}
+</style>

+ 499 - 0
frontend/src/pages/xray/OutboundsTab.vue

@@ -0,0 +1,499 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  PlusOutlined,
+  CloudOutlined,
+  ApiOutlined,
+  RetweetOutlined,
+  MoreOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  VerticalAlignTopOutlined,
+  ThunderboltOutlined,
+  CheckCircleFilled,
+  CloseCircleFilled,
+  LoadingOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+} from '@ant-design/icons-vue';
+import { Modal } from 'ant-design-vue';
+
+import { SizeFormatter } from '@/utils';
+import { Protocols } from '@/models/outbound.js';
+import OutboundFormModal from './OutboundFormModal.vue';
+
+const { t } = useI18n();
+
+// Outbounds tab — list + actions over templateSettings.outbounds.
+// Mirrors the legacy outbound table layout (identity / address /
+// traffic / test result / test button) plus the row action menu
+// (set first / edit / reset traffic / delete). Mobile collapses to
+// a card list.
+
+const props = defineProps({
+  templateSettings: { type: Object, default: null },
+  outboundsTraffic: { type: Array, default: () => [] },
+  outboundTestStates: { type: Object, default: () => ({}) },
+  isMobile: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(['reset-traffic', 'test', 'show-warp', 'show-nord']);
+
+// === Modal state ====================================================
+const modalOpen = ref(false);
+const editingOutbound = ref(null);
+const editingIndex = ref(null);
+const existingTags = ref([]);
+
+function openAdd() {
+  editingOutbound.value = null;
+  editingIndex.value = null;
+  existingTags.value = (props.templateSettings?.outbounds || []).map((o) => o.tag);
+  modalOpen.value = true;
+}
+function openEdit(idx) {
+  editingOutbound.value = props.templateSettings.outbounds[idx];
+  editingIndex.value = idx;
+  existingTags.value = (props.templateSettings?.outbounds || [])
+    .filter((_, i) => i !== idx)
+    .map((o) => o.tag);
+  modalOpen.value = true;
+}
+function onConfirm(outbound) {
+  if (editingIndex.value == null) {
+    if (!outbound.tag) return;
+    props.templateSettings.outbounds.push(outbound);
+  } else {
+    props.templateSettings.outbounds[editingIndex.value] = outbound;
+  }
+  modalOpen.value = false;
+}
+
+function confirmDelete(idx) {
+  Modal.confirm({
+    title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: () => { props.templateSettings.outbounds.splice(idx, 1); },
+  });
+}
+function setFirst(idx) {
+  const arr = props.templateSettings.outbounds;
+  arr.unshift(arr.splice(idx, 1)[0]);
+}
+function moveUp(idx) {
+  if (idx <= 0) return;
+  const arr = props.templateSettings.outbounds;
+  [arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]];
+}
+function moveDown(idx) {
+  const arr = props.templateSettings.outbounds;
+  if (idx >= arr.length - 1) return;
+  [arr[idx + 1], arr[idx]] = [arr[idx], arr[idx + 1]];
+}
+
+// === Per-row helpers ================================================
+function trafficFor(o) {
+  const t = props.outboundsTraffic.find((x) => x.tag === o.tag);
+  return { up: t?.up || 0, down: t?.down || 0 };
+}
+
+// Lifted from legacy findOutboundAddress — returns an array of
+// "host:port" strings for the protocols that have one, or null when
+// the outbound has no externally-visible endpoint (Freedom, Blackhole,
+// DNS without an explicit address, etc.).
+function outboundAddresses(o) {
+  let serverObj;
+  switch (o.protocol) {
+    case Protocols.VMess:
+      serverObj = o.settings?.vnext;
+      break;
+    case Protocols.VLESS:
+      return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
+    case Protocols.HTTP:
+    case Protocols.Socks:
+    case Protocols.Shadowsocks:
+    case Protocols.Trojan:
+      serverObj = o.settings?.servers;
+      break;
+    case Protocols.DNS:
+      return [`${o.settings?.address || ''}:${o.settings?.port || ''}`];
+    case Protocols.Wireguard:
+      return (o.settings?.peers || []).map((p) => p.endpoint);
+    default:
+      return [];
+  }
+  return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : [];
+}
+
+function isUntestable(o) {
+  return o.protocol === 'blackhole' || o.tag === 'blocked';
+}
+function isTesting(idx) {
+  return !!props.outboundTestStates?.[idx]?.testing;
+}
+function testResult(idx) {
+  return props.outboundTestStates?.[idx]?.result || null;
+}
+function showSecurity(security) {
+  return security === 'tls' || security === 'reality';
+}
+
+// === Columns ========================================================
+// Computed so titles re-render after a locale swap.
+const columns = computed(() => [
+  { title: '#', key: 'action', align: 'center', width: 70 },
+  { title: 'Tag', key: 'identity', align: 'left', width: 220 },
+  { title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
+  { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
+  { title: t('check'), key: 'testResult', align: 'left', width: 140 },
+  { title: t('check'), key: 'test', align: 'center', width: 80 },
+]);
+
+const rows = computed(() => {
+  if (!props.templateSettings?.outbounds) return [];
+  return props.templateSettings.outbounds.map((o, i) => ({ key: i, ...o }));
+});
+</script>
+
+<template>
+  <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+    <!-- Toolbar -->
+    <a-row :gutter="[12, 12]" align="middle" justify="space-between">
+      <a-col :xs="24" :sm="14">
+        <a-space size="small">
+          <a-button type="primary" @click="openAdd">
+            <template #icon><PlusOutlined /></template>
+            <span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
+          </a-button>
+          <a-button type="primary" @click="emit('show-warp')">
+            <template #icon><CloudOutlined /></template>
+            WARP
+          </a-button>
+          <a-button type="primary" @click="emit('show-nord')">
+            <template #icon><ApiOutlined /></template>
+            NordVPN
+          </a-button>
+        </a-space>
+      </a-col>
+      <a-col :xs="24" :sm="10" class="toolbar-right">
+        <a-popconfirm
+          placement="topRight"
+          :ok-text="t('reset')"
+          :cancel-text="t('cancel')"
+          :title="t('pages.inbounds.resetAllTrafficContent')"
+          @confirm="emit('reset-traffic', '-alltags-')"
+        >
+          <a-button>
+            <template #icon><RetweetOutlined /></template>
+          </a-button>
+        </a-popconfirm>
+      </a-col>
+    </a-row>
+
+    <!-- Mobile: card list -->
+    <template v-if="isMobile">
+      <div v-if="rows.length === 0" class="card-empty">—</div>
+      <div v-for="(record, index) in rows" :key="record.key" class="outbound-card">
+        <div class="card-head">
+          <div class="card-identity">
+            <span class="card-num">{{ index + 1 }}</span>
+            <a-tooltip :title="record.tag">
+              <span class="tag-name">{{ record.tag }}</span>
+            </a-tooltip>
+            <a-tag color="green">{{ record.protocol }}</a-tag>
+            <template
+              v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
+            >
+              <a-tag>{{ record.streamSettings?.network }}</a-tag>
+              <a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
+                {{ record.streamSettings.security }}
+              </a-tag>
+            </template>
+          </div>
+          <a-dropdown :trigger="['click']">
+            <a-button shape="circle" size="small">
+              <MoreOutlined />
+            </a-button>
+            <template #overlay>
+              <a-menu>
+                <a-menu-item v-if="index > 0" @click="setFirst(index)">
+                  <VerticalAlignTopOutlined />
+                </a-menu-item>
+                <a-menu-item @click="openEdit(index)">
+                  <EditOutlined /> {{ t('edit') }}
+                </a-menu-item>
+                <a-menu-item @click="emit('reset-traffic', record.tag || '')">
+                  <RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
+                </a-menu-item>
+                <a-menu-item class="danger" @click="confirmDelete(index)">
+                  <DeleteOutlined /> {{ t('delete') }}
+                </a-menu-item>
+              </a-menu>
+            </template>
+          </a-dropdown>
+        </div>
+        <div v-if="outboundAddresses(record).length > 0" class="address-list">
+          <a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
+            <span class="address-pill">{{ addr }}</span>
+          </a-tooltip>
+        </div>
+        <div class="card-foot">
+          <span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
+          <span class="traffic-sep" />
+          <span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
+          <span class="card-test">
+            <span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
+              <CheckCircleFilled v-if="testResult(index).success" />
+              <CloseCircleFilled v-else />
+              <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
+              <span v-else>failed</span>
+            </span>
+            <LoadingOutlined v-else-if="isTesting(index)" />
+            <a-button
+              type="primary"
+              shape="circle"
+              size="small"
+              :loading="isTesting(index)"
+              :disabled="isUntestable(record) || isTesting(index)"
+              @click="emit('test', index)"
+            >
+              <template #icon><ThunderboltOutlined /></template>
+            </a-button>
+          </span>
+        </div>
+      </div>
+    </template>
+
+    <!-- Desktop: table -->
+    <a-table
+      v-else
+      :columns="columns"
+      :data-source="rows"
+      :row-key="(r) => r.key"
+      :pagination="false"
+      size="small"
+    >
+      <template #bodyCell="{ column, record, index }">
+        <template v-if="column.key === 'action'">
+          <div class="action-cell">
+            <span class="row-index">{{ index + 1 }}</span>
+            <a-dropdown :trigger="['click']">
+              <a-button shape="circle" size="small">
+                <MoreOutlined />
+              </a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item v-if="index > 0" @click="setFirst(index)">
+                    <VerticalAlignTopOutlined /> Move to top
+                  </a-menu-item>
+                  <a-menu-item @click="openEdit(index)">
+                    <EditOutlined /> Edit
+                  </a-menu-item>
+                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                    <ArrowUpOutlined />
+                  </a-menu-item>
+                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                    <ArrowDownOutlined />
+                  </a-menu-item>
+                  <a-menu-item @click="emit('reset-traffic', record.tag || '')">
+                    <RetweetOutlined /> Reset traffic
+                  </a-menu-item>
+                  <a-menu-item class="danger" @click="confirmDelete(index)">
+                    <DeleteOutlined /> Delete
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </div>
+        </template>
+
+        <template v-else-if="column.key === 'identity'">
+          <div class="identity-cell">
+            <a-tooltip :title="record.tag">
+              <span class="tag-name">{{ record.tag }}</span>
+            </a-tooltip>
+            <div class="protocol-line">
+              <a-tag color="green">{{ record.protocol }}</a-tag>
+              <template
+                v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
+              >
+                <a-tag>{{ record.streamSettings?.network }}</a-tag>
+                <a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
+                  {{ record.streamSettings.security }}
+                </a-tag>
+              </template>
+            </div>
+          </div>
+        </template>
+
+        <template v-else-if="column.key === 'address'">
+          <div class="address-list">
+            <a-tooltip v-for="addr in outboundAddresses(record)" :key="addr" :title="addr">
+              <span class="address-pill">{{ addr }}</span>
+            </a-tooltip>
+            <span v-if="outboundAddresses(record).length === 0" class="empty">—</span>
+          </div>
+        </template>
+
+        <template v-else-if="column.key === 'traffic'">
+          <span class="traffic-up">↑ {{ SizeFormatter.sizeFormat(trafficFor(record).up) }}</span>
+          <span class="traffic-sep" />
+          <span class="traffic-down">↓ {{ SizeFormatter.sizeFormat(trafficFor(record).down) }}</span>
+        </template>
+
+        <template v-else-if="column.key === 'testResult'">
+          <span v-if="testResult(index)" :class="testResult(index).success ? 'pill-ok' : 'pill-fail'">
+            <CheckCircleFilled v-if="testResult(index).success" />
+            <CloseCircleFilled v-else />
+            <span v-if="testResult(index).success">{{ testResult(index).delay }}&nbsp;ms</span>
+            <a-tooltip v-else :title="testResult(index).error">
+              <span>failed</span>
+            </a-tooltip>
+          </span>
+          <LoadingOutlined v-else-if="isTesting(index)" />
+          <span v-else class="empty">—</span>
+        </template>
+
+        <template v-else-if="column.key === 'test'">
+          <a-tooltip :title="t('check')">
+            <a-button
+              type="primary"
+              shape="circle"
+              :loading="isTesting(index)"
+              :disabled="isUntestable(record) || isTesting(index)"
+              @click="emit('test', index)"
+            >
+              <template #icon><ThunderboltOutlined /></template>
+            </a-button>
+          </a-tooltip>
+        </template>
+      </template>
+    </a-table>
+
+    <OutboundFormModal
+      v-model:open="modalOpen"
+      :outbound="editingOutbound"
+      :existing-tags="existingTags"
+      @confirm="onConfirm"
+    />
+  </a-space>
+</template>
+
+<style scoped>
+.toolbar-right { display: flex; justify-content: flex-end; }
+
+.card-empty {
+  text-align: center;
+  opacity: 0.4;
+  padding: 16px 0;
+}
+.outbound-card {
+  border: 1px solid rgba(128, 128, 128, 0.2);
+  border-radius: 8px;
+  padding: 12px;
+  margin-bottom: 8px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.card-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 8px;
+}
+.card-identity {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+}
+.card-num {
+  font-weight: 500;
+  opacity: 0.7;
+  min-width: 18px;
+  text-align: right;
+}
+.tag-name {
+  font-weight: 500;
+  max-width: 200px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+}
+.protocol-line {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 2px;
+}
+
+.address-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 4px;
+}
+.address-pill {
+  font-size: 11px;
+  padding: 2px 6px;
+  border-radius: 4px;
+  background: rgba(0, 0, 0, 0.05);
+}
+:global(body.dark) .address-pill {
+  background: rgba(255, 255, 255, 0.06);
+}
+
+.action-cell {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row-index {
+  font-weight: 500;
+  opacity: 0.7;
+  min-width: 18px;
+  text-align: right;
+}
+
+.identity-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  min-width: 0;
+}
+
+.card-foot {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+}
+.card-test {
+  margin-left: auto;
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.traffic-up { color: #008771; font-size: 12px; }
+.traffic-down { color: #3c89e8; font-size: 12px; }
+.traffic-sep { display: inline-block; width: 4px; }
+
+.pill-ok,
+.pill-fail {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 1px 8px;
+  border-radius: 12px;
+  font-size: 12px;
+}
+.pill-ok { color: #008771; background: rgba(0, 135, 113, 0.12); }
+.pill-fail { color: #e04141; background: rgba(224, 65, 65, 0.12); }
+
+.empty { opacity: 0.4; }
+.danger { color: #ff4d4f; }
+</style>

+ 405 - 0
frontend/src/pages/xray/RoutingTab.vue

@@ -0,0 +1,405 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import {
+  PlusOutlined,
+  MoreOutlined,
+  EditOutlined,
+  DeleteOutlined,
+  ExportOutlined,
+  ClusterOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+} from '@ant-design/icons-vue';
+import { Modal } from 'ant-design-vue';
+
+import RuleFormModal from './RuleFormModal.vue';
+
+const { t } = useI18n();
+
+// Routing tab — table over templateSettings.routing.rules with the
+// modernised legacy column layout. Each row is rendered as a single
+// "lead value + N more" pill per criterion (matches the legacy pill
+// layout); full lists surface via tooltip on hover.
+//
+// Reorder uses up/down buttons in the action menu rather than the
+// jQuery-Sortable drag handle the legacy panel used — same effect,
+// no extra dep. The mobile column layout drops source/network/
+// destination criteria for readability.
+
+const props = defineProps({
+  templateSettings: { type: Object, default: null },
+  inboundTags: { type: Array, default: () => [] },
+  clientReverseTags: { type: Array, default: () => [] },
+  isMobile: { type: Boolean, default: false },
+});
+
+// === Table data — match the legacy routingRuleData shape ============
+// Convert array criteria to CSV strings so the pill renderer can
+// split + summarise them without needing a separate path per shape.
+const rows = computed(() => {
+  if (!props.templateSettings?.routing?.rules) return [];
+  return props.templateSettings.routing.rules.map((rule, idx) => {
+    const r = { key: idx, ...rule };
+    if (Array.isArray(r.domain)) r.domain = r.domain.join(',');
+    if (Array.isArray(r.ip)) r.ip = r.ip.join(',');
+    if (Array.isArray(r.source)) r.source = r.source.join(',');
+    if (Array.isArray(r.user)) r.user = r.user.join(',');
+    if (Array.isArray(r.inboundTag)) r.inboundTag = r.inboundTag.join(',');
+    if (Array.isArray(r.protocol)) r.protocol = r.protocol.join(',');
+    if (r.attrs && typeof r.attrs === 'object' && !Array.isArray(r.attrs)) {
+      r.attrs = JSON.stringify(r.attrs, null, 2);
+    }
+    return r;
+  });
+});
+
+function csv(value) {
+  if (!value) return [];
+  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
+}
+
+// === Modal state ====================================================
+const ruleModalOpen = ref(false);
+const editingRule = ref(null);
+const editingIndex = ref(null);
+
+const inboundTagOptions = computed(() => {
+  const out = new Set();
+  for (const ib of props.templateSettings?.inbounds || []) {
+    if (ib.tag) out.add(ib.tag);
+  }
+  for (const t of props.inboundTags || []) out.add(t);
+  // dnsTag if DNS is configured.
+  const dt = props.templateSettings?.dns?.tag;
+  if (dt) out.add(dt);
+  return [...out];
+});
+
+const outboundTagOptions = computed(() => {
+  const out = new Set(['']);
+  for (const ob of props.templateSettings?.outbounds || []) {
+    if (ob.tag) out.add(ob.tag);
+  }
+  for (const t of props.clientReverseTags || []) {
+    if (t) out.add(t);
+  }
+  return [...out];
+});
+
+const balancerTagOptions = computed(() => {
+  const out = [''];
+  for (const b of props.templateSettings?.routing?.balancers || []) {
+    if (b.tag) out.push(b.tag);
+  }
+  return out;
+});
+
+function openAdd() {
+  editingRule.value = null;
+  editingIndex.value = null;
+  ruleModalOpen.value = true;
+}
+
+function openEdit(idx) {
+  editingRule.value = props.templateSettings.routing.rules[idx];
+  editingIndex.value = idx;
+  ruleModalOpen.value = true;
+}
+
+function onRuleConfirm(rule) {
+  // Empty submit (e.g. user clears every field) collapses to an
+  // object with only `type: "field"`. Match legacy: skip the write
+  // when the result is essentially empty.
+  if (JSON.stringify(rule).length <= 3) {
+    ruleModalOpen.value = false;
+    return;
+  }
+  if (editingIndex.value == null) {
+    props.templateSettings.routing.rules.push(rule);
+  } else {
+    props.templateSettings.routing.rules[editingIndex.value] = rule;
+  }
+  ruleModalOpen.value = false;
+}
+
+function confirmDelete(idx) {
+  Modal.confirm({
+    title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
+    okText: t('delete'),
+    okType: 'danger',
+    cancelText: t('cancel'),
+    onOk: () => { props.templateSettings.routing.rules.splice(idx, 1); },
+  });
+}
+
+function moveUp(idx) {
+  if (idx <= 0) return;
+  const rules = props.templateSettings.routing.rules;
+  [rules[idx - 1], rules[idx]] = [rules[idx], rules[idx - 1]];
+}
+function moveDown(idx) {
+  const rules = props.templateSettings.routing.rules;
+  if (idx >= rules.length - 1) return;
+  [rules[idx + 1], rules[idx]] = [rules[idx], rules[idx + 1]];
+}
+
+// === Columns =========================================================
+// Computed so titles re-render after a locale swap.
+const desktopColumns = computed(() => [
+  { title: '#', align: 'center', width: 70, key: 'action' },
+  { title: 'Source', align: 'left', width: 180, key: 'source' },
+  { title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
+  { title: 'Destination', align: 'left', key: 'destination' },
+  { title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
+  { title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
+]);
+const mobileColumns = computed(() => [
+  { title: '#', align: 'center', width: 70, key: 'action' },
+  { title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
+  { title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
+]);
+const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
+</script>
+
+<template>
+  <a-space direction="vertical" size="middle" :style="{ width: '100%' }">
+    <a-button type="primary" @click="openAdd">
+      <template #icon><PlusOutlined /></template>
+      {{ t('pages.xray.Routings') }}
+    </a-button>
+
+    <a-table
+      :columns="columns"
+      :data-source="rows"
+      :row-key="(r) => r.key"
+      :pagination="false"
+      :scroll="isMobile ? {} : { x: 1000 }"
+      size="small"
+      class="routing-table"
+    >
+      <template #bodyCell="{ column, record, index }">
+        <!-- ============== # / actions ============== -->
+        <template v-if="column.key === 'action'">
+          <div class="action-cell">
+            <span class="row-index">{{ index + 1 }}</span>
+            <a-dropdown :trigger="['click']">
+              <a-button shape="circle" size="small">
+                <MoreOutlined />
+              </a-button>
+              <template #overlay>
+                <a-menu>
+                  <a-menu-item @click="openEdit(index)">
+                    <EditOutlined /> {{ t('edit') }}
+                  </a-menu-item>
+                  <a-menu-item :disabled="index === 0" @click="moveUp(index)">
+                    <ArrowUpOutlined />
+                  </a-menu-item>
+                  <a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
+                    <ArrowDownOutlined />
+                  </a-menu-item>
+                  <a-menu-item class="danger" @click="confirmDelete(index)">
+                    <DeleteOutlined /> {{ t('delete') }}
+                  </a-menu-item>
+                </a-menu>
+              </template>
+            </a-dropdown>
+          </div>
+        </template>
+
+        <!-- ============== Source ============== -->
+        <template v-else-if="column.key === 'source'">
+          <div class="criterion-flow">
+            <a-tooltip v-if="record.sourceIP" :title="`Source IP: ${record.sourceIP}`">
+              <span class="criterion-row">
+                <span class="criterion-label">IP</span>
+                <span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
+                <span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Port</span>
+                <span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
+                <span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
+              <span class="criterion-row">
+                <span class="criterion-label">VLESS</span>
+                <span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
+                <span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
+          </div>
+        </template>
+
+        <!-- ============== Network ============== -->
+        <template v-else-if="column.key === 'network'">
+          <div class="criterion-flow">
+            <a-tooltip v-if="record.network" :title="`L4: ${record.network}`">
+              <span class="criterion-row">
+                <span class="criterion-label">L4</span>
+                <span class="criterion-value">{{ csv(record.network)[0] }}</span>
+                <span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Protocol</span>
+                <span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
+                <span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Attrs</span>
+                <span class="criterion-value">{{ csv(record.attrs)[0] }}</span>
+              </span>
+            </a-tooltip>
+            <span v-if="!record.network && !record.protocol && !record.attrs" class="criterion-empty">—</span>
+          </div>
+        </template>
+
+        <!-- ============== Destination ============== -->
+        <template v-else-if="column.key === 'destination'">
+          <div class="criterion-flow">
+            <a-tooltip v-if="record.ip" :title="`Destination IP: ${record.ip}`">
+              <span class="criterion-row">
+                <span class="criterion-label">IP</span>
+                <span class="criterion-value">{{ csv(record.ip)[0] }}</span>
+                <span v-if="csv(record.ip).length > 1" class="criterion-more">+{{ csv(record.ip).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.domain" :title="`Domain: ${record.domain}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Domain</span>
+                <span class="criterion-value">{{ csv(record.domain)[0] }}</span>
+                <span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Port</span>
+                <span class="criterion-value">{{ csv(record.port)[0] }}</span>
+                <span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
+          </div>
+        </template>
+
+        <!-- ============== Inbound ============== -->
+        <template v-else-if="column.key === 'inbound'">
+          <div class="criterion-flow">
+            <a-tooltip v-if="record.inboundTag" :title="`Inbound tag: ${record.inboundTag}`">
+              <span class="criterion-row">
+                <span class="criterion-label">Tag</span>
+                <span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
+                <span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <a-tooltip v-if="record.user" :title="`User: ${record.user}`">
+              <span class="criterion-row">
+                <span class="criterion-label">User</span>
+                <span class="criterion-value">{{ csv(record.user)[0] }}</span>
+                <span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 }}</span>
+              </span>
+            </a-tooltip>
+            <span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
+          </div>
+        </template>
+
+        <!-- ============== Outbound / balancer target ============== -->
+        <template v-else-if="column.key === 'target'">
+          <div class="target-cell">
+            <div v-if="record.outboundTag" class="target-row">
+              <ExportOutlined class="target-icon" />
+              <a-tag color="green">{{ record.outboundTag }}</a-tag>
+            </div>
+            <div v-if="record.balancerTag" class="target-row">
+              <ClusterOutlined class="target-icon" />
+              <a-tag color="purple">{{ record.balancerTag }}</a-tag>
+            </div>
+            <span v-if="!record.outboundTag && !record.balancerTag" class="criterion-empty">—</span>
+          </div>
+        </template>
+      </template>
+    </a-table>
+
+    <RuleFormModal
+      v-model:open="ruleModalOpen"
+      :rule="editingRule"
+      :inbound-tags="inboundTagOptions"
+      :outbound-tags="outboundTagOptions"
+      :balancer-tags="balancerTagOptions"
+      @confirm="onRuleConfirm"
+    />
+  </a-space>
+</template>
+
+<style scoped>
+.action-cell {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+.row-index {
+  font-weight: 500;
+  opacity: 0.7;
+  min-width: 18px;
+  text-align: right;
+}
+
+.criterion-flow {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  font-size: 12px;
+}
+.criterion-row {
+  display: inline-flex;
+  align-items: baseline;
+  gap: 4px;
+  white-space: nowrap;
+}
+.criterion-label {
+  font-size: 10px;
+  text-transform: uppercase;
+  opacity: 0.55;
+  letter-spacing: 0.04em;
+}
+.criterion-value {
+  font-weight: 500;
+}
+.criterion-more {
+  font-size: 11px;
+  padding: 0 5px;
+  border-radius: 8px;
+  background: rgba(0, 0, 0, 0.06);
+}
+:global(body.dark) .criterion-more {
+  background: rgba(255, 255, 255, 0.1);
+}
+.criterion-empty {
+  opacity: 0.4;
+}
+
+.target-cell {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.target-row {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.target-icon {
+  font-size: 12px;
+  opacity: 0.6;
+}
+
+.danger { color: #ff4d4f; }
+</style>

+ 263 - 0
frontend/src/pages/xray/RuleFormModal.vue

@@ -0,0 +1,263 @@
+<script setup>
+import { computed, reactive, ref, watch } from 'vue';
+import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons-vue';
+
+// Routing-rule editor — mirrors xray_rule_modal.html. We keep the
+// CSV-style fields (domain / ip / sourceIP / user / port / sourcePort /
+// vlessRoute) as plain strings while the modal is open and split them
+// back to arrays on submit, just like the legacy ruleModal.getResult.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  // null when adding, the rule object when editing.
+  rule: { type: Object, default: null },
+  // Tag pools sourced from templateSettings.{inbounds,outbounds,routing.balancers}
+  // and the parent's inboundTags / clientReverseTags / dnsTag.
+  inboundTags: { type: Array, default: () => [] },
+  outboundTags: { type: Array, default: () => [] },
+  balancerTags: { type: Array, default: () => [''] },
+});
+
+const emit = defineEmits(['update:open', 'confirm']);
+
+const form = reactive({
+  domain: '',
+  ip: '',
+  port: '',
+  sourcePort: '',
+  vlessRoute: '',
+  network: '',
+  sourceIP: '',
+  user: '',
+  inboundTag: [],
+  protocol: [],
+  attrs: [], // [[key, value], ...]
+  outboundTag: '',
+  balancerTag: '',
+});
+
+const isEdit = ref(false);
+
+function reset() {
+  form.domain = '';
+  form.ip = '';
+  form.port = '';
+  form.sourcePort = '';
+  form.vlessRoute = '';
+  form.network = '';
+  form.sourceIP = '';
+  form.user = '';
+  form.inboundTag = [];
+  form.protocol = [];
+  form.attrs = [];
+  form.outboundTag = '';
+  form.balancerTag = '';
+}
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  if (props.rule) {
+    isEdit.value = true;
+    const r = props.rule;
+    form.domain = Array.isArray(r.domain) ? r.domain.join(',') : (r.domain || '');
+    form.ip = Array.isArray(r.ip) ? r.ip.join(',') : (r.ip || '');
+    form.port = r.port || '';
+    form.sourcePort = r.sourcePort || '';
+    form.vlessRoute = r.vlessRoute || '';
+    form.network = r.network || '';
+    form.sourceIP = Array.isArray(r.sourceIP) ? r.sourceIP.join(',') : (r.sourceIP || '');
+    form.user = Array.isArray(r.user) ? r.user.join(',') : (r.user || '');
+    form.inboundTag = r.inboundTag || [];
+    form.protocol = r.protocol || [];
+    // Attrs in the wire shape are an object — flatten to [[k,v]] pairs.
+    form.attrs = r.attrs ? Object.entries(r.attrs) : [];
+    form.outboundTag = r.outboundTag || '';
+    form.balancerTag = r.balancerTag || '';
+  } else {
+    isEdit.value = false;
+    reset();
+  }
+});
+
+function close() { emit('update:open', false); }
+
+function csv(value) {
+  if (!value) return [];
+  return String(value).split(',').map((s) => s.trim()).filter(Boolean);
+}
+
+function buildResult() {
+  const rule = {
+    type: 'field',
+    domain: csv(form.domain),
+    ip: csv(form.ip),
+    port: form.port,
+    sourcePort: form.sourcePort,
+    vlessRoute: form.vlessRoute,
+    network: form.network,
+    sourceIP: csv(form.sourceIP),
+    user: csv(form.user),
+    inboundTag: form.inboundTag,
+    protocol: form.protocol,
+    attrs: Object.fromEntries(form.attrs.filter(([k]) => k)),
+    outboundTag: form.outboundTag === '' ? undefined : form.outboundTag,
+    balancerTag: form.balancerTag === '' ? undefined : form.balancerTag,
+  };
+  // Strip empty arrays / objects / strings so the final wire JSON
+  // matches what the legacy `getResult` produces.
+  const out = {};
+  for (const [k, v] of Object.entries(rule)) {
+    if (v == null) continue;
+    if (Array.isArray(v) && v.length === 0) continue;
+    if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
+    if (v === '') continue;
+    out[k] = v;
+  }
+  return out;
+}
+
+function onOk() {
+  emit('confirm', buildResult());
+}
+
+import { useI18n } from 'vue-i18n';
+const { t } = useI18n();
+
+const title = computed(() =>
+  isEdit.value
+    ? `${t('edit')} ${t('pages.xray.Routings')}`
+    : `+ ${t('pages.xray.Routings')}`,
+);
+const okText = computed(() =>
+  isEdit.value ? t('pages.client.submitEdit') : t('create'),
+);
+
+const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
+const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    :title="title"
+    :ok-text="okText"
+    :cancel-text="t('close')"
+    :mask-closable="false"
+    width="640px"
+    @ok="onOk"
+    @cancel="close"
+  >
+    <a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">
+            Source IPs <QuestionCircleOutlined />
+          </a-tooltip>
+        </template>
+        <a-input v-model:value="form.sourceIP" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">
+            Source port <QuestionCircleOutlined />
+          </a-tooltip>
+        </template>
+        <a-input v-model:value="form.sourcePort" placeholder="53,443,1000-2000" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">
+            VLESS route <QuestionCircleOutlined />
+          </a-tooltip>
+        </template>
+        <a-input v-model:value="form.vlessRoute" placeholder="53,443,1000-2000" />
+      </a-form-item>
+
+      <a-form-item label="Network">
+        <a-select v-model:value="form.network">
+          <a-select-option v-for="n in NETWORKS" :key="n" :value="n">{{ n || '(any)' }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item label="Protocol">
+        <a-select v-model:value="form.protocol" mode="multiple">
+          <a-select-option v-for="p in PROTOCOLS" :key="p" :value="p">{{ p }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item label="Attributes">
+        <a-button size="small" @click="form.attrs.push(['', ''])">
+          <template #icon><PlusOutlined /></template>
+        </a-button>
+      </a-form-item>
+      <a-form-item :wrapper-col="{ span: 24 }">
+        <a-input-group v-for="(attr, idx) in form.attrs" :key="idx" compact class="mb-8">
+          <a-input :style="{ width: '45%' }" v-model:value="attr[0]" placeholder="Name">
+            <template #addonBefore>{{ idx + 1 }}</template>
+          </a-input>
+          <a-input :style="{ width: '45%' }" v-model:value="attr[1]" placeholder="Value" />
+          <a-button @click="form.attrs.splice(idx, 1)">
+            <template #icon><MinusOutlined /></template>
+          </a-button>
+        </a-input-group>
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">IP <QuestionCircleOutlined /></a-tooltip>
+        </template>
+        <a-input v-model:value="form.ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">Domain <QuestionCircleOutlined /></a-tooltip>
+        </template>
+        <a-input v-model:value="form.domain" placeholder="google.com, geosite:cn" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">User <QuestionCircleOutlined /></a-tooltip>
+        </template>
+        <a-input v-model:value="form.user" placeholder="email address" />
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Comma-separated list">Port <QuestionCircleOutlined /></a-tooltip>
+        </template>
+        <a-input v-model:value="form.port" placeholder="53,443,1000-2000" />
+      </a-form-item>
+
+      <a-form-item label="Inbound tags">
+        <a-select v-model:value="form.inboundTag" mode="multiple">
+          <a-select-option v-for="tag in inboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item label="Outbound tag">
+        <a-select v-model:value="form.outboundTag">
+          <a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
+        </a-select>
+      </a-form-item>
+
+      <a-form-item>
+        <template #label>
+          <a-tooltip title="Routes traffic through one of the configured load balancers">
+            Balancer tag <QuestionCircleOutlined />
+          </a-tooltip>
+        </template>
+        <a-select v-model:value="form.balancerTag">
+          <a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
+        </a-select>
+      </a-form-item>
+    </a-form>
+  </a-modal>
+</template>
+
+<style scoped>
+.mb-8 { margin-bottom: 8px; }
+</style>

+ 347 - 0
frontend/src/pages/xray/WarpModal.vue

@@ -0,0 +1,347 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue';
+import { message } from 'ant-design-vue';
+
+import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
+
+// Cloudflare WARP provisioning modal. Mirrors the legacy warp_modal:
+//   • when no WARP account is registered yet, a single Create button
+//     generates a wireguard keypair locally and posts it to
+//     /panel/xray/warp/reg to create a Cloudflare device record;
+//   • once registered, the modal displays the access_token /
+//     device_id / license_key / private_key, lets the user upgrade
+//     to WARP+ via /panel/xray/warp/license, fetches the current
+//     account config (premium data / quota / usage) via
+//     /panel/xray/warp/config, and stages a wireguard outbound
+//     ready for adding to templateSettings.outbounds.
+
+const props = defineProps({
+  open: { type: Boolean, default: false },
+  templateSettings: { type: Object, default: null },
+});
+
+const emit = defineEmits(['update:open', 'add-outbound', 'reset-outbound', 'remove-outbound']);
+
+const loading = ref(false);
+const warpData = ref(null);
+const warpConfig = ref(null);
+const warpPlus = ref('');
+// Held in memory so the parent's add/reset handlers receive the same
+// object the modal computed from getConfig().
+const stagedOutbound = ref(null);
+
+const warpOutboundIndex = computed(() => {
+  const list = props.templateSettings?.outbounds;
+  if (!list) return -1;
+  return list.findIndex((o) => o?.tag === 'warp');
+});
+
+watch(() => props.open, (next) => {
+  if (!next) return;
+  warpConfig.value = null;
+  stagedOutbound.value = null;
+  fetchData();
+});
+
+async function fetchData() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/warp/data');
+    if (msg?.success) {
+      const raw = msg.obj;
+      warpData.value = raw && raw.length > 0 ? JSON.parse(raw) : null;
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function register() {
+  loading.value = true;
+  try {
+    const keys = Wireguard.generateKeypair();
+    const msg = await HttpUtil.post('/panel/xray/warp/reg', keys);
+    if (msg?.success) {
+      const resp = JSON.parse(msg.obj);
+      warpData.value = resp.data;
+      warpConfig.value = resp.config;
+      collectConfig();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function getConfig() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/warp/config');
+    if (msg?.success) {
+      warpConfig.value = JSON.parse(msg.obj);
+      collectConfig();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function updateLicense() {
+  if (warpPlus.value.length < 26) return;
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/warp/license', { license: warpPlus.value });
+    if (msg?.success) {
+      warpData.value = JSON.parse(msg.obj);
+      warpConfig.value = null;
+      warpPlus.value = '';
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function delConfig() {
+  loading.value = true;
+  try {
+    const msg = await HttpUtil.post('/panel/xray/warp/del');
+    if (msg?.success) {
+      warpData.value = null;
+      warpConfig.value = null;
+      stagedOutbound.value = null;
+      emit('remove-outbound', 'warp');
+      close();
+    }
+  } finally {
+    loading.value = false;
+  }
+}
+
+// Build the wireguard outbound shape from the WARP account data.
+// Keep this here (not on the parent) because the encoding of the
+// reserved bytes from `client_id` is WARP-specific.
+function collectConfig() {
+  const config = warpConfig.value?.config;
+  if (!config?.peers?.length) return;
+  const peer = config.peers[0];
+  stagedOutbound.value = {
+    tag: 'warp',
+    protocol: 'wireguard',
+    settings: {
+      mtu: 1420,
+      secretKey: warpData.value.private_key,
+      address: addressesFor(config.interface?.addresses || {}),
+      reserved: reservedFor(warpData.value.client_id),
+      domainStrategy: 'ForceIP',
+      peers: [{
+        publicKey: peer.public_key,
+        endpoint: peer.endpoint?.host,
+      }],
+      noKernelTun: false,
+    },
+  };
+}
+
+function addressesFor(addrs) {
+  const out = [];
+  if (addrs.v4) out.push(`${addrs.v4}/32`);
+  if (addrs.v6) out.push(`${addrs.v6}/128`);
+  return out;
+}
+
+// WARP encodes its reserved bytes as a base64-decoded triplet pulled
+// from `client_id`. We turn those bytes into an int array — same
+// algorithm the legacy modal used.
+function reservedFor(clientId) {
+  if (!clientId) return [];
+  const decoded = atob(clientId);
+  const out = [];
+  for (let i = 0; i < decoded.length; i++) out.push(decoded.charCodeAt(i));
+  return out;
+}
+
+function addOutbound() {
+  if (!stagedOutbound.value) {
+    message.warning('Fetch the WARP config first.');
+    return;
+  }
+  emit('add-outbound', stagedOutbound.value);
+  close();
+}
+
+function resetOutbound() {
+  if (!stagedOutbound.value) return;
+  emit('reset-outbound', { index: warpOutboundIndex.value, outbound: stagedOutbound.value });
+  close();
+}
+
+function close() { emit('update:open', false); }
+
+const hasWarp = computed(() => !ObjectUtil.isEmpty(warpData.value));
+const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
+</script>
+
+<template>
+  <a-modal
+    :open="open"
+    title="Cloudflare WARP"
+    :footer="null"
+    :closable="true"
+    :mask-closable="true"
+    @cancel="close"
+  >
+    <!-- WARP / NordVPN provisioning forms keep technical wire labels in
+         English on purpose: they map directly to API field names users
+         look up in vendor docs. Only the primary action buttons +
+         dialog headers translate. -->
+    <!-- Not registered yet → single Create CTA -->
+    <template v-if="!hasWarp">
+      <a-button type="primary" :loading="loading" @click="register">
+        <template #icon><ApiOutlined /></template>
+        Create WARP account
+      </a-button>
+    </template>
+
+    <!-- Registered → account display + license + config + outbound controls -->
+    <template v-else>
+      <table class="warp-data-table">
+        <tbody>
+          <tr class="row-odd">
+            <td>Access token</td>
+            <td>{{ warpData.access_token }}</td>
+          </tr>
+          <tr>
+            <td>Device ID</td>
+            <td>{{ warpData.device_id }}</td>
+          </tr>
+          <tr class="row-odd">
+            <td>License key</td>
+            <td>{{ warpData.license_key }}</td>
+          </tr>
+          <tr>
+            <td>Private key</td>
+            <td>{{ warpData.private_key }}</td>
+          </tr>
+        </tbody>
+      </table>
+
+      <a-button :loading="loading" type="primary" danger class="mt-8" @click="delConfig">
+        <template #icon><DeleteOutlined /></template>
+        Delete account
+      </a-button>
+
+      <a-divider class="zero-margin">Settings</a-divider>
+
+      <a-collapse class="my-10">
+        <a-collapse-panel header="WARP / WARP+ license key">
+          <a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
+            <a-form-item label="Key">
+              <a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
+              <a-button
+                type="primary"
+                class="mt-8"
+                :disabled="warpPlus.length < 26"
+                :loading="loading"
+                @click="updateLicense"
+              >Update</a-button>
+            </a-form-item>
+          </a-form>
+        </a-collapse-panel>
+      </a-collapse>
+
+      <a-divider class="zero-margin">Account info</a-divider>
+      <a-button class="my-8" :loading="loading" type="primary" @click="getConfig">
+        <template #icon><SyncOutlined /></template>
+        Refresh
+      </a-button>
+
+      <template v-if="hasConfig">
+        <table class="warp-data-table">
+          <tbody>
+            <tr class="row-odd">
+              <td>Device name</td>
+              <td>{{ warpConfig.name }}</td>
+            </tr>
+            <tr>
+              <td>Device model</td>
+              <td>{{ warpConfig.model }}</td>
+            </tr>
+            <tr class="row-odd">
+              <td>Device enabled</td>
+              <td>{{ warpConfig.enabled }}</td>
+            </tr>
+            <template v-if="warpConfig.account">
+              <tr>
+                <td>Account type</td>
+                <td>{{ warpConfig.account.account_type }}</td>
+              </tr>
+              <tr class="row-odd">
+                <td>Role</td>
+                <td>{{ warpConfig.account.role }}</td>
+              </tr>
+              <tr>
+                <td>WARP+ data</td>
+                <td>{{ SizeFormatter.sizeFormat(warpConfig.account.premium_data) }}</td>
+              </tr>
+              <tr class="row-odd">
+                <td>Quota</td>
+                <td>{{ SizeFormatter.sizeFormat(warpConfig.account.quota) }}</td>
+              </tr>
+              <tr v-if="warpConfig.account.usage">
+                <td>Usage</td>
+                <td>{{ SizeFormatter.sizeFormat(warpConfig.account.usage) }}</td>
+              </tr>
+            </template>
+          </tbody>
+        </table>
+
+        <a-divider class="my-10">Outbound status</a-divider>
+        <template v-if="warpOutboundIndex >= 0">
+          <a-tag color="green">Enabled</a-tag>
+          <a-button type="primary" danger :loading="loading" class="ml-8" @click="resetOutbound">
+            Reset
+          </a-button>
+        </template>
+        <template v-else>
+          <a-tag color="orange">Disabled</a-tag>
+          <a-button type="primary" :loading="loading" class="ml-8" @click="addOutbound">
+            <template #icon><PlusOutlined /></template>
+            Add outbound
+          </a-button>
+        </template>
+      </template>
+    </template>
+  </a-modal>
+</template>
+
+<style scoped>
+.warp-data-table {
+  margin: 5px 0;
+  width: 100%;
+  border-collapse: collapse;
+}
+.warp-data-table td {
+  padding: 4px 8px;
+  word-break: break-all;
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+.warp-data-table td:first-child {
+  font-family: inherit;
+  font-weight: 500;
+  white-space: nowrap;
+  width: 130px;
+}
+.row-odd {
+  background: rgba(0, 0, 0, 0.03);
+}
+:global(body.dark) .row-odd {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+.zero-margin { margin: 0; }
+.my-8 { margin: 8px 0; }
+.mt-8 { margin-top: 8px; }
+.my-10 { margin: 10px 0; }
+.ml-8 { margin-left: 8px; }
+</style>

+ 431 - 0
frontend/src/pages/xray/XrayPage.vue

@@ -0,0 +1,431 @@
+<script setup>
+import { computed, ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Modal, message } from 'ant-design-vue';
+import {
+  SettingOutlined,
+  SwapOutlined,
+  UploadOutlined,
+  ClusterOutlined,
+  DatabaseOutlined,
+  CodeOutlined,
+  QuestionCircleOutlined,
+} from '@ant-design/icons-vue';
+
+import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
+import { useMediaQuery } from '@/composables/useMediaQuery.js';
+import AppSidebar from '@/components/AppSidebar.vue';
+import BasicsTab from './BasicsTab.vue';
+import RoutingTab from './RoutingTab.vue';
+import OutboundsTab from './OutboundsTab.vue';
+import BalancersTab from './BalancersTab.vue';
+import DnsTab from './DnsTab.vue';
+import WarpModal from './WarpModal.vue';
+import NordModal from './NordModal.vue';
+import { useXraySetting } from './useXraySetting.js';
+import { useWebSocket } from '@/composables/useWebSocket.js';
+
+const { t } = useI18n();
+
+const {
+  fetched,
+  spinning,
+  saveDisabled,
+  fetchError,
+  xraySetting,
+  templateSettings,
+  outboundTestUrl,
+  inboundTags,
+  clientReverseTags,
+  restartResult,
+  outboundsTraffic,
+  outboundTestStates,
+  fetchAll,
+  resetOutboundsTraffic,
+  testOutbound,
+  saveAll,
+  resetToDefault,
+  restartXray,
+  applyOutboundsEvent,
+} = useXraySetting();
+
+// Live outbounds traffic — pushed by xray_traffic_job every ~10s.
+useWebSocket({ outbounds: applyOutboundsEvent });
+
+async function onTestOutbound(idx) {
+  const outbound = templateSettings.value?.outbounds?.[idx];
+  if (outbound) await testOutbound(idx, outbound);
+}
+
+// === Advanced tab — radio-driven view ==============================
+// Mirrors the legacy advanced page: a 4-way radio toggles which slice
+// of the xray config the textarea edits — the full config, just the
+// inbounds, just the outbounds, or just the routing rules. Each slice
+// reads/writes through templateSettings so edits propagate to the
+// dirty-poll and structured tabs.
+const advSettings = ref('xraySetting');
+
+const advancedText = computed({
+  get: () => {
+    if (advSettings.value === 'xraySetting') return xraySetting.value;
+    const t = templateSettings.value;
+    if (!t) return '';
+    try {
+      switch (advSettings.value) {
+        case 'inboundSettings':
+          return JSON.stringify(t.inbounds || [], null, 2);
+        case 'outboundSettings':
+          return JSON.stringify(t.outbounds || [], null, 2);
+        case 'routingRuleSettings':
+          return JSON.stringify(t.routing?.rules || [], null, 2);
+        default:
+          return '';
+      }
+    } catch (_e) {
+      return '';
+    }
+  },
+  set: (next) => {
+    if (advSettings.value === 'xraySetting') {
+      xraySetting.value = next;
+      return;
+    }
+    // Slice edits: parse-then-merge into templateSettings so the
+    // structured tabs and the dirty-poll re-stringify it cleanly.
+    let parsed;
+    try { parsed = JSON.parse(next); } catch (_e) { return; }
+    const t = templateSettings.value;
+    if (!t) return;
+    switch (advSettings.value) {
+      case 'inboundSettings':
+        t.inbounds = parsed;
+        break;
+      case 'outboundSettings':
+        t.outbounds = parsed;
+        break;
+      case 'routingRuleSettings':
+        if (!t.routing) t.routing = {};
+        t.routing.rules = parsed;
+        break;
+    }
+  },
+});
+
+// `WarpExist` / `NordExist` derive from the parsed templateSettings —
+// the Basics tab gates its WARP / NordVPN domain selectors on whether
+// the matching outbound is provisioned, falling back to a "configure"
+// button that today just toasts (the modals land in 6-v).
+const warpExist = computed(
+  () => !!templateSettings.value?.outbounds?.find((o) => o?.tag === 'warp'),
+);
+const nordExist = computed(
+  () => !!templateSettings.value?.outbounds?.find((o) => o?.tag?.startsWith?.('nord-')),
+);
+
+// === WARP / NordVPN provisioning modals ============================
+const warpOpen = ref(false);
+const nordOpen = ref(false);
+
+function showWarp() { warpOpen.value = true; }
+function showNord() { nordOpen.value = true; }
+
+function ensureOutbounds() {
+  if (!templateSettings.value) return null;
+  if (!Array.isArray(templateSettings.value.outbounds)) {
+    templateSettings.value.outbounds = [];
+  }
+  return templateSettings.value.outbounds;
+}
+
+function onAddOutbound(outbound) {
+  const list = ensureOutbounds();
+  if (list) list.push(outbound);
+}
+function onResetOutbound({ index, outbound, oldTag, newTag }) {
+  const list = ensureOutbounds();
+  if (!list || index < 0) return;
+  list[index] = outbound;
+  // Tag rename across routing rules — preserves Nord's
+  // server-switch flow without dangling references.
+  if (oldTag && newTag && oldTag !== newTag) {
+    const rules = templateSettings.value?.routing?.rules || [];
+    for (const r of rules) {
+      if (r?.outboundTag === oldTag) r.outboundTag = newTag;
+    }
+  }
+}
+function onRemoveOutboundByTag(tag) {
+  const list = ensureOutbounds();
+  if (!list) return;
+  const idx = list.findIndex((o) => o?.tag === tag);
+  if (idx >= 0) list.splice(idx, 1);
+}
+function onRemoveOutboundByIndex(index) {
+  const list = ensureOutbounds();
+  if (list && index >= 0) list.splice(index, 1);
+}
+function onRemoveRoutingRules({ prefix }) {
+  const rules = templateSettings.value?.routing?.rules;
+  if (!Array.isArray(rules)) return;
+  templateSettings.value.routing.rules = rules.filter(
+    (r) => !r?.outboundTag?.startsWith?.(prefix),
+  );
+}
+
+// `message` is used by some of the in-progress UX flows (kept around
+// because future provisioning errors will surface through it).
+void message;
+const { isMobile } = useMediaQuery();
+
+const basePath = window.__X_UI_BASE_PATH__ || '';
+const requestUri = window.location.pathname;
+
+// See SettingsPage scrollTarget — wrap so `document` is in scope.
+function scrollTarget() {
+  return document.getElementById('content-layout');
+}
+
+function confirmRestart() {
+  Modal.confirm({
+    title: 'Restart xray?',
+    content: 'Reloads the xray service with the saved configuration.',
+    okText: 'Restart',
+    cancelText: 'Cancel',
+    onOk: () => restartXray(),
+  });
+}
+</script>
+
+<template>
+  <a-config-provider :theme="antdThemeConfig">
+    <a-layout
+      class="xray-page"
+      :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
+    >
+      <AppSidebar :base-path="basePath" :request-uri="requestUri" />
+
+      <a-layout class="content-shell">
+        <a-layout-content id="content-layout" class="content-area">
+          <a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
+            <div v-if="!fetched" class="loading-spacer" />
+
+            <a-result
+              v-else-if="fetchError"
+              status="error"
+              :title="t('somethingWentWrong')"
+              :sub-title="fetchError"
+            >
+              <template #extra>
+                <a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
+              </template>
+            </a-result>
+
+            <template v-else>
+              <a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
+                <!-- Save / Restart bar -->
+                <a-col :span="24">
+                  <a-card hoverable>
+                    <a-row class="header-row">
+                      <a-col :xs="24" :sm="14" class="header-actions">
+                        <a-space direction="horizontal">
+                          <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
+                            {{ t('pages.xray.save') }}
+                          </a-button>
+                          <a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
+                            {{ t('pages.xray.restart') }}
+                          </a-button>
+                          <a-popover v-if="restartResult" placement="rightTop">
+                            <template #title>Xray restart output</template>
+                            <template #content>
+                              <pre class="restart-result">{{ restartResult }}</pre>
+                            </template>
+                            <QuestionCircleOutlined class="restart-icon" />
+                          </a-popover>
+                        </a-space>
+                      </a-col>
+                      <a-col :xs="24" :sm="10" class="header-info">
+                        <a-back-top :target="scrollTarget" :visibility-height="200" />
+                        <a-alert
+                          type="warning"
+                          show-icon
+                          :message="t('pages.settings.infoDesc')"
+                        />
+                      </a-col>
+                    </a-row>
+                  </a-card>
+                </a-col>
+
+                <!-- Tabs -->
+                <a-col :span="24">
+                  <a-tabs default-active-key="tpl-basic">
+                    <a-tab-pane key="tpl-basic" class="tab-pane">
+                      <template #tab>
+                        <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
+                      </template>
+                      <BasicsTab
+                        :template-settings="templateSettings"
+                        :outbound-test-url="outboundTestUrl"
+                        :warp-exist="warpExist"
+                        :nord-exist="nordExist"
+                        @update:outbound-test-url="(v) => (outboundTestUrl = v)"
+                        @show-warp="showWarp"
+                        @show-nord="showNord"
+                        @reset-default="resetToDefault"
+                      />
+                    </a-tab-pane>
+
+                    <a-tab-pane key="tpl-routing" class="tab-pane">
+                      <template #tab>
+                        <SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
+                      </template>
+                      <RoutingTab
+                        :template-settings="templateSettings"
+                        :inbound-tags="inboundTags"
+                        :client-reverse-tags="clientReverseTags"
+                        :is-mobile="isMobile"
+                      />
+                    </a-tab-pane>
+
+                    <a-tab-pane key="tpl-outbound" class="tab-pane">
+                      <template #tab>
+                        <UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
+                      </template>
+                      <OutboundsTab
+                        :template-settings="templateSettings"
+                        :outbounds-traffic="outboundsTraffic"
+                        :outbound-test-states="outboundTestStates"
+                        :is-mobile="isMobile"
+                        @reset-traffic="resetOutboundsTraffic"
+                        @test="onTestOutbound"
+                        @show-warp="showWarp"
+                        @show-nord="showNord"
+                      />
+                    </a-tab-pane>
+
+                    <a-tab-pane key="tpl-balancer" class="tab-pane">
+                      <template #tab>
+                        <ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
+                      </template>
+                      <BalancersTab :template-settings="templateSettings" />
+                    </a-tab-pane>
+
+                    <a-tab-pane key="tpl-dns" class="tab-pane">
+                      <template #tab>
+                        <DatabaseOutlined /> <span>DNS</span>
+                      </template>
+                      <DnsTab :template-settings="templateSettings" />
+                    </a-tab-pane>
+
+                    <a-tab-pane key="tpl-advanced" class="tab-pane">
+                      <template #tab>
+                        <CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
+                      </template>
+                      <a-list-item-meta
+                        :title="t('pages.xray.Template')"
+                        :description="t('pages.xray.TemplateDesc')"
+                      />
+                      <a-radio-group
+                        v-model:value="advSettings"
+                        button-style="solid"
+                        :size="isMobile ? 'small' : 'middle'"
+                        :style="{ margin: '12px 0' }"
+                      >
+                        <a-radio-button value="xraySetting">{{ t('pages.xray.completeTemplate') }}</a-radio-button>
+                        <a-radio-button value="inboundSettings">{{ t('pages.xray.Inbounds') }}</a-radio-button>
+                        <a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
+                        <a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
+                      </a-radio-group>
+                      <a-textarea
+                        v-model:value="advancedText"
+                        :auto-size="{ minRows: 18, maxRows: 40 }"
+                        spellcheck="false"
+                        class="json-editor"
+                      />
+                    </a-tab-pane>
+                  </a-tabs>
+                </a-col>
+              </a-row>
+            </template>
+          </a-spin>
+        </a-layout-content>
+      </a-layout>
+
+      <WarpModal
+        v-model:open="warpOpen"
+        :template-settings="templateSettings"
+        @add-outbound="onAddOutbound"
+        @reset-outbound="onResetOutbound"
+        @remove-outbound="onRemoveOutboundByTag"
+      />
+      <NordModal
+        v-model:open="nordOpen"
+        :template-settings="templateSettings"
+        @add-outbound="onAddOutbound"
+        @reset-outbound="onResetOutbound"
+        @remove-outbound="onRemoveOutboundByIndex"
+        @remove-routing-rules="onRemoveRoutingRules"
+      />
+    </a-layout>
+  </a-config-provider>
+</template>
+
+<style scoped>
+.xray-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.xray-page.is-dark {
+  --bg-page: #0a1222;
+  --bg-card: #151f31;
+}
+
+.xray-page.is-dark.is-ultra {
+  --bg-page: #050505;
+  --bg-card: #0c0e12;
+}
+
+.xray-page :deep(.ant-layout),
+.xray-page :deep(.ant-layout-content) {
+  background: transparent;
+}
+
+.content-shell { background: transparent; }
+.content-area { padding: 24px; }
+
+.loading-spacer { min-height: calc(100vh - 120px); }
+
+.header-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+}
+.header-actions { padding: 4px; }
+.header-info {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.tab-pane { padding-top: 20px; }
+
+.restart-icon {
+  font-size: 16px;
+  cursor: pointer;
+  color: var(--ant-primary-color, #1890ff);
+}
+
+.restart-result {
+  max-width: 480px;
+  white-space: pre-wrap;
+  font-size: 12px;
+  margin: 0;
+}
+
+.json-editor {
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  font-size: 12px;
+}
+</style>

+ 246 - 0
frontend/src/pages/xray/useXraySetting.js

@@ -0,0 +1,246 @@
+// Drives the xray page's fetch / dirty / save lifecycle. The Go side
+// returns the live xraySetting (the full JSON config), the inboundTags
+// list, and a few sidecar values (clientReverseTags, outboundTestUrl)
+// the structured tabs need. We keep the JSON as a string here — pretty-
+// printed for the textarea; tabs that want a parsed view can JSON.parse
+// it themselves.
+
+import { onMounted, onUnmounted, ref, watch } from 'vue';
+import { HttpUtil, PromiseUtil } from '@/utils';
+
+const DIRTY_POLL_MS = 1000;
+
+// Hoists the parsed `templateSettings` alongside the JSON string so
+// structured tabs (Basics/Routing/Outbounds/etc.) can mutate fields
+// directly while the Advanced (JSON) tab edits the same data as text.
+// We keep both in sync with two cooperating watches:
+//   • mutating templateSettings re-stringifies into xraySetting;
+//   • editing the JSON text re-parses into templateSettings (only on
+//     valid JSON — invalid edits leave templateSettings untouched
+//     so the structured tabs don't blow up while the user types).
+let syncing = false;
+
+export function useXraySetting() {
+  const fetched = ref(false);
+  const spinning = ref(false);
+  const saveDisabled = ref(true);
+  // Holds a user-facing message when fetchAll fails; lets the page
+  // render an error UI instead of an endless spinner.
+  const fetchError = ref('');
+
+  const xraySetting = ref('');
+  const oldXraySetting = ref('');
+
+  // Parsed mirror — null until first successful fetch / parse.
+  const templateSettings = ref(null);
+
+  const outboundTestUrl = ref('https://www.google.com/generate_204');
+  const oldOutboundTestUrl = ref('');
+
+  const inboundTags = ref([]);
+  const clientReverseTags = ref([]);
+  const restartResult = ref('');
+
+  // Outbounds tab data — traffic stats + per-row test state. Test
+  // states are keyed by outbound index (sparse object), each entry
+  // is `{ testing, result }` where result is the wire response from
+  // /panel/xray/testOutbound or null while the test is in flight.
+  const outboundsTraffic = ref([]);
+  const outboundTestStates = ref({});
+
+  async function fetchAll() {
+    fetchError.value = '';
+    const msg = await HttpUtil.post('/panel/xray/');
+    if (!msg?.success) {
+      fetchError.value = msg?.msg || 'Failed to load xray config';
+      // Mark as fetched so the spinner clears and the error UI renders.
+      fetched.value = true;
+      return;
+    }
+    let obj;
+    try {
+      obj = JSON.parse(msg.obj);
+    } catch (e) {
+      fetchError.value = `Malformed xray config response: ${e?.message || e}`;
+      fetched.value = true;
+      return;
+    }
+    const pretty = JSON.stringify(obj.xraySetting, null, 2);
+    syncing = true;
+    xraySetting.value = pretty;
+    oldXraySetting.value = pretty;
+    templateSettings.value = obj.xraySetting;
+    syncing = false;
+    inboundTags.value = obj.inboundTags || [];
+    clientReverseTags.value = obj.clientReverseTags || [];
+    outboundTestUrl.value = obj.outboundTestUrl || 'https://www.google.com/generate_204';
+    oldOutboundTestUrl.value = outboundTestUrl.value;
+    fetched.value = true;
+    saveDisabled.value = true;
+  }
+
+  // Structured tabs mutate templateSettings deeply. Re-stringify on
+  // change so the Advanced JSON view + the dirty-poll see the edits.
+  watch(
+    templateSettings,
+    (next) => {
+      if (syncing || !next) return;
+      syncing = true;
+      try {
+        xraySetting.value = JSON.stringify(next, null, 2);
+      } finally {
+        syncing = false;
+      }
+    },
+    { deep: true },
+  );
+
+  // Advanced JSON edits — only refresh templateSettings when the text
+  // parses, so structured tabs stay readable mid-edit.
+  watch(xraySetting, (next) => {
+    if (syncing) return;
+    try {
+      const parsed = JSON.parse(next);
+      syncing = true;
+      try {
+        templateSettings.value = parsed;
+      } finally {
+        syncing = false;
+      }
+    } catch (_e) { /* ignore — wait for user to finish */ }
+  });
+
+  async function saveAll() {
+    spinning.value = true;
+    try {
+      const msg = await HttpUtil.post('/panel/xray/update', {
+        xraySetting: xraySetting.value,
+        outboundTestUrl: outboundTestUrl.value || 'https://www.google.com/generate_204',
+      });
+      if (msg?.success) await fetchAll();
+    } finally {
+      spinning.value = false;
+    }
+  }
+
+  async function fetchOutboundsTraffic() {
+    const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
+    if (msg?.success) outboundsTraffic.value = msg.obj || [];
+  }
+
+  async function resetOutboundsTraffic(tag) {
+    const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
+    if (msg?.success) await fetchOutboundsTraffic();
+  }
+
+  // Merges a WebSocket `outbounds` event into outboundsTraffic in place.
+  // The xray traffic job pushes the full snapshot every ~10s so the user
+  // doesn't have to click the (now-removed) refresh button.
+  function applyOutboundsEvent(payload) {
+    if (Array.isArray(payload)) outboundsTraffic.value = payload;
+  }
+
+  async function testOutbound(index, outbound) {
+    if (!outbound) return null;
+    if (!outboundTestStates.value[index]) outboundTestStates.value[index] = {};
+    outboundTestStates.value[index] = { testing: true, result: null };
+    try {
+      const msg = await HttpUtil.post('/panel/xray/testOutbound', {
+        outbound: JSON.stringify(outbound),
+        allOutbounds: JSON.stringify(templateSettings.value?.outbounds || []),
+      });
+      if (msg?.success) {
+        outboundTestStates.value[index] = { testing: false, result: msg.obj };
+        return msg.obj;
+      }
+      outboundTestStates.value[index] = {
+        testing: false,
+        result: { success: false, error: msg?.msg || 'Unknown error' },
+      };
+    } catch (e) {
+      outboundTestStates.value[index] = {
+        testing: false,
+        result: { success: false, error: String(e) },
+      };
+    }
+    return null;
+  }
+
+  async function resetToDefault() {
+    spinning.value = true;
+    try {
+      const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
+      if (msg?.success) {
+        // Mutate templateSettings — the watch above re-stringifies into
+        // xraySetting so the Advanced JSON tab and dirty-poll see it.
+        templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
+      }
+    } finally {
+      spinning.value = false;
+    }
+  }
+
+  async function restartXray() {
+    spinning.value = true;
+    try {
+      const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
+      if (msg?.success) {
+        // Match legacy: short pause, then poll for the result blob so
+        // the popover surfaces any startup error from the new process.
+        await PromiseUtil.sleep(500);
+        const r = await HttpUtil.get('/panel/xray/getXrayResult');
+        if (r?.success) restartResult.value = r.obj || '';
+      }
+    } finally {
+      spinning.value = false;
+    }
+  }
+
+  // Same 1s busy-loop pattern the settings page uses — keep it cheap
+  // and consistent. Real work (the JSON diff) is just a string compare.
+  let timer = null;
+  function startDirtyPoll() {
+    if (timer != null) return;
+    timer = setInterval(() => {
+      saveDisabled.value =
+        oldXraySetting.value === xraySetting.value
+        && oldOutboundTestUrl.value === outboundTestUrl.value;
+    }, DIRTY_POLL_MS);
+  }
+  function stopDirtyPoll() {
+    if (timer != null) {
+      clearInterval(timer);
+      timer = null;
+    }
+  }
+
+  onMounted(() => {
+    fetchAll();
+    fetchOutboundsTraffic();
+    startDirtyPoll();
+  });
+  onUnmounted(stopDirtyPoll);
+
+  return {
+    fetched,
+    spinning,
+    saveDisabled,
+    fetchError,
+    xraySetting,
+    templateSettings,
+    outboundTestUrl,
+    inboundTags,
+    clientReverseTags,
+    restartResult,
+    outboundsTraffic,
+    outboundTestStates,
+    fetchAll,
+    fetchOutboundsTraffic,
+    resetOutboundsTraffic,
+    applyOutboundsEvent,
+    testOutbound,
+    saveAll,
+    resetToDefault,
+    restartXray,
+  };
+}

+ 87 - 82
web/assets/js/util/index.js → frontend/src/utils/index.js

@@ -1,4 +1,7 @@
-class Msg {
+import axios from 'axios';
+import { message as antMessage } from 'ant-design-vue';
+
+export class Msg {
     constructor(success = false, msg = "", obj = null) {
         this.success = success;
         this.msg = msg;
@@ -6,13 +9,13 @@ class Msg {
     }
 }
 
-class HttpUtil {
+export class HttpUtil {
     static _handleMsg(msg) {
         if (!(msg instanceof Msg) || msg.msg === "") {
             return;
         }
         const messageType = msg.success ? 'success' : 'error';
-        Vue.prototype.$message[messageType](msg.msg);
+        antMessage[messageType](msg.msg);
     }
 
     static _respToMsg(resp) {
@@ -72,7 +75,7 @@ class HttpUtil {
     }
 }
 
-class PromiseUtil {
+export class PromiseUtil {
     static async sleep(timeout) {
         await new Promise(resolve => {
             setTimeout(resolve, timeout)
@@ -80,7 +83,7 @@ class PromiseUtil {
     }
 }
 
-class RandomUtil {
+export class RandomUtil {
     static getSeq({ type = "default", hasNumbers = true, hasLowercase = true, hasUppercase = true } = {}) {
         let seq = '';
 
@@ -138,10 +141,10 @@ class RandomUtil {
         }
     }
 
-    static randomShadowsocksPassword(method = SSMethods.BLAKE3_AES_256_GCM) {
+    static randomShadowsocksPassword(method = '2022-blake3-aes-256-gcm') {
         let length = 32;
 
-        if ([SSMethods.BLAKE3_AES_128_GCM].includes(method)) {
+        if (method === '2022-blake3-aes-128-gcm') {
             length = 16;
         }
 
@@ -186,10 +189,10 @@ class RandomUtil {
     }
 }
 
-class ObjectUtil {
+export class ObjectUtil {
     static getPropIgnoreCase(obj, prop) {
         for (const name in obj) {
-            if (!obj.hasOwnProperty(name)) {
+            if (!Object.prototype.hasOwnProperty.call(obj, name)) {
                 continue;
             }
             if (name.toLowerCase() === prop.toLowerCase()) {
@@ -208,7 +211,7 @@ class ObjectUtil {
             }
         } else if (obj instanceof Object) {
             for (let name in obj) {
-                if (!obj.hasOwnProperty(name)) {
+                if (!Object.prototype.hasOwnProperty.call(obj, name)) {
                     continue;
                 }
                 if (this.deepSearch(obj[name], key)) {
@@ -276,9 +279,9 @@ class ObjectUtil {
         }
         const ignoreEmpty = this.isArrEmpty(ignoreProps);
         for (const key of Object.keys(src)) {
-            if (!src.hasOwnProperty(key)) {
+            if (!Object.prototype.hasOwnProperty.call(src, key)) {
                 continue;
-            } else if (!dest.hasOwnProperty(key)) {
+            } else if (!Object.prototype.hasOwnProperty.call(dest, key)) {
                 continue;
             } else if (src[key] === undefined) {
                 continue;
@@ -334,7 +337,7 @@ class ObjectUtil {
     }
 }
 
-class Wireguard {
+export class Wireguard {
     static gf(init) {
         var r = new Float64Array(16);
         if (init) {
@@ -345,15 +348,16 @@ class Wireguard {
     }
 
     static pack(o, n) {
-        var b, m = this.gf(), t = this.gf();
-        for (var i = 0; i < 16; ++i)
+        let b;
+        const m = this.gf(), t = this.gf();
+        for (let i = 0; i < 16; ++i)
             t[i] = n[i];
         this.carry(t);
         this.carry(t);
         this.carry(t);
-        for (var j = 0; j < 2; ++j) {
+        for (let j = 0; j < 2; ++j) {
             m[0] = t[0] - 0xffed;
-            for (var i = 1; i < 15; ++i) {
+            for (let i = 1; i < 15; ++i) {
                 m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
                 m[i - 1] &= 0xffff;
             }
@@ -362,23 +366,23 @@ class Wireguard {
             m[14] &= 0xffff;
             this.cswap(t, m, 1 - b);
         }
-        for (var i = 0; i < 16; ++i) {
+        for (let i = 0; i < 16; ++i) {
             o[2 * i] = t[i] & 0xff;
             o[2 * i + 1] = t[i] >> 8;
         }
     }
 
     static carry(o) {
-        var c;
-        for (var i = 0; i < 16; ++i) {
+        for (let i = 0; i < 16; ++i) {
             o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
             o[i] &= 0xffff;
         }
     }
 
     static cswap(p, q, b) {
-        var t, c = ~(b - 1);
-        for (var i = 0; i < 16; ++i) {
+        const c = ~(b - 1);
+        let t;
+        for (let i = 0; i < 16; ++i) {
             t = c & (p[i] ^ q[i]);
             p[i] ^= t;
             q[i] ^= t;
@@ -386,39 +390,39 @@ class Wireguard {
     }
 
     static add(o, a, b) {
-        for (var i = 0; i < 16; ++i)
+        for (let i = 0; i < 16; ++i)
             o[i] = (a[i] + b[i]) | 0;
     }
 
     static subtract(o, a, b) {
-        for (var i = 0; i < 16; ++i)
+        for (let i = 0; i < 16; ++i)
             o[i] = (a[i] - b[i]) | 0;
     }
 
     static multmod(o, a, b) {
-        var t = new Float64Array(31);
-        for (var i = 0; i < 16; ++i) {
-            for (var j = 0; j < 16; ++j)
+        const t = new Float64Array(31);
+        for (let i = 0; i < 16; ++i) {
+            for (let j = 0; j < 16; ++j)
                 t[i + j] += a[i] * b[j];
         }
-        for (var i = 0; i < 15; ++i)
+        for (let i = 0; i < 15; ++i)
             t[i] += 38 * t[i + 16];
-        for (var i = 0; i < 16; ++i)
+        for (let i = 0; i < 16; ++i)
             o[i] = t[i];
         this.carry(o);
         this.carry(o);
     }
 
     static invert(o, i) {
-        var c = this.gf();
-        for (var a = 0; a < 16; ++a)
+        const c = this.gf();
+        for (let a = 0; a < 16; ++a)
             c[a] = i[a];
-        for (var a = 253; a >= 0; --a) {
+        for (let a = 253; a >= 0; --a) {
             this.multmod(c, c, c);
             if (a !== 2 && a !== 4)
                 this.multmod(c, c, i);
         }
-        for (var a = 0; a < 16; ++a)
+        for (let a = 0; a < 16; ++a)
             o[a] = c[a];
     }
 
@@ -428,8 +432,9 @@ class Wireguard {
     }
 
     static generatePublicKey(privateKey) {
-        var r, z = new Uint8Array(32);
-        var a = this.gf([1]),
+        let r;
+        const z = new Uint8Array(32);
+        const a = this.gf([1]),
             b = this.gf([9]),
             c = this.gf(),
             d = this.gf([1]),
@@ -437,10 +442,10 @@ class Wireguard {
             f = this.gf(),
             _121665 = this.gf([0xdb41, 1]),
             _9 = this.gf([9]);
-        for (var i = 0; i < 32; ++i)
+        for (let i = 0; i < 32; ++i)
             z[i] = privateKey[i];
         this.clamp(z);
-        for (var i = 254; i >= 0; --i) {
+        for (let i = 254; i >= 0; --i) {
             r = (z[i >>> 3] >>> (i & 7)) & 1;
             this.cswap(a, b, r);
             this.cswap(c, d, r);
@@ -521,7 +526,7 @@ class Wireguard {
     }
 }
 
-class ClipboardManager {
+export class ClipboardManager {
     static copyText(content = "") {
         // !! here old way of copying is used because not everyone can afford https connection
         return new Promise((resolve) => {
@@ -553,7 +558,7 @@ class ClipboardManager {
     }
 }
 
-class Base64 {
+export class Base64 {
     static encode(content = "", safe = false) {
         if (safe) {
             return Base64.encode(content)
@@ -581,7 +586,7 @@ class Base64 {
     }
 }
 
-class SizeFormatter {
+export class SizeFormatter {
     static ONE_KB = 1024;
     static ONE_MB = this.ONE_KB * 1024;
     static ONE_GB = this.ONE_MB * 1024;
@@ -599,7 +604,7 @@ class SizeFormatter {
     }
 }
 
-class CPUFormatter {
+export class CPUFormatter {
     static cpuSpeedFormat(speed) {
         return speed > 1000 ? (speed / 1000).toFixed(2) + " GHz" : speed.toFixed(2) + " MHz";
     }
@@ -609,7 +614,7 @@ class CPUFormatter {
     }
 }
 
-class TimeFormatter {
+export class TimeFormatter {
     static formatSecond(second) {
         if (second < 60) return second.toFixed(0) + 's';
         if (second < 3600) return (second / 60).toFixed(0) + 'm';
@@ -620,7 +625,7 @@ class TimeFormatter {
     }
 }
 
-class NumberFormatter {
+export class NumberFormatter {
     static addZero(num) {
         return num < 10 ? "0" + num : num;
     }
@@ -631,7 +636,7 @@ class NumberFormatter {
     }
 }
 
-class Utils {
+export class Utils {
     static debounce(fn, delay) {
         let timeoutID = null;
         return function () {
@@ -643,7 +648,7 @@ class Utils {
     }
 }
 
-class CookieManager {
+export class CookieManager {
     static getCookie(cname) {
         let name = cname + '=';
         let ca = document.cookie.split(';');
@@ -667,7 +672,18 @@ class CookieManager {
     }
 }
 
-class ColorUtils {
+// AD-Vue 4 semantic palette — kept in one place so the client/inbound
+// rows match the rest of the panel. Purple is reserved for the
+// "no quota / no expiry / unlimited" sentinel since the AD-Vue green
+// would otherwise read as "healthy / under limit".
+const COLORS = {
+    success: '#52c41a', // AD-Vue success — within quota
+    warning: '#faad14', // AD-Vue gold — close to quota / about to expire
+    danger: '#ff4d4f',  // AD-Vue red — depleted / expired
+    purple: '#722ed1',  // AD-Vue purple — unlimited / no expiry
+};
+
+export class ColorUtils {
     static usageColor(data, threshold, total) {
         switch (true) {
             case data === null: return "purple";
@@ -681,10 +697,10 @@ class ColorUtils {
 
     static clientUsageColor(clientStats, trafficDiff) {
         switch (true) {
-            case !clientStats || clientStats.total == 0: return "#7a316f";
-            case clientStats.up + clientStats.down < clientStats.total - trafficDiff: return "#008771";
-            case clientStats.up + clientStats.down < clientStats.total: return "#f37b24";
-            default: return "#cf3c3c";
+            case !clientStats || clientStats.total == 0: return COLORS.purple;
+            case clientStats.up + clientStats.down < clientStats.total - trafficDiff: return COLORS.success;
+            case clientStats.up + clientStats.down < clientStats.total: return COLORS.warning;
+            default: return COLORS.danger;
         }
     }
 
@@ -692,23 +708,23 @@ class ColorUtils {
         if (!client.enable) return isDark ? '#2c3950' : '#bcbcbc';
         let now = new Date().getTime(), expiry = client.expiryTime;
         switch (true) {
-            case expiry === null: return "#7a316f";
-            case expiry < 0: return "#008771";
-            case expiry == 0: return "#7a316f";
-            case now < expiry - threshold: return "#008771";
-            case now < expiry: return "#f37b24";
-            default: return "#cf3c3c";
+            case expiry === null: return COLORS.purple;
+            case expiry < 0: return COLORS.success;
+            case expiry == 0: return COLORS.purple;
+            case now < expiry - threshold: return COLORS.success;
+            case now < expiry: return COLORS.warning;
+            default: return COLORS.danger;
         }
     }
 }
 
-class ArrayUtils {
+export class ArrayUtils {
     static doAllItemsExist(array1, array2) {
         return array1.every(item => array2.includes(item));
     }
 }
 
-class URLBuilder {
+export class URLBuilder {
     static buildURL({ host, port, isTLS, base, path }) {
         if (!host || host.length === 0) host = window.location.hostname;
         if (!port || port.length === 0) port = window.location.port;
@@ -726,7 +742,7 @@ class URLBuilder {
     }
 }
 
-class LanguageManager {
+export class LanguageManager {
     static supportedLanguages = [
         {
             name: "العربية",
@@ -854,26 +870,7 @@ class LanguageManager {
     }
 }
 
-const MediaQueryMixin = {
-    data() {
-        return {
-            isMobile: window.innerWidth <= 768,
-        };
-    },
-    methods: {
-        updateDeviceType() {
-            this.isMobile = window.innerWidth <= 768;
-        },
-    },
-    mounted() {
-        window.addEventListener('resize', this.updateDeviceType);
-    },
-    beforeDestroy() {
-        window.removeEventListener('resize', this.updateDeviceType);
-    },
-}
-
-class FileManager {
+export class FileManager {
     static downloadTextFile(content, filename = 'file.txt', options = { type: "text/plain" }) {
         let link = window.document.createElement('a');
 
@@ -893,9 +890,17 @@ class FileManager {
     }
 }
 
-class IntlUtil {
-    static formatDate(date) {
+export class IntlUtil {
+    // When `calendar` is "jalalian", append the BCP-47 calendar extension
+    // so Intl renders the date in the Persian (Jalali/Shamsi) calendar
+    // regardless of the UI language. Without it, only locales that
+    // default to Persian (e.g. fa-IR) would show Jalali; en-US/ru/etc.
+    // would keep showing Gregorian.
+    static formatDate(date, calendar = "gregorian") {
         const language = LanguageManager.getLanguage()
+        const locale = calendar === "jalalian"
+            ? `${language}-u-ca-persian`
+            : language
 
         let intlOptions = {
             year: "numeric",
@@ -907,7 +912,7 @@ class IntlUtil {
         }
 
         const intl = new Intl.DateTimeFormat(
-            language,
+            locale,
             intlOptions
         )
 

部分文件因文件數量過多而無法顯示