Переглянути джерело

Vue3 migration (#4198)

* docs(migration): Phase 1 inventory — Vue 2 / AD-Vue 1 surface area

Captures the breakage surface for the Vue 3 + Ant Design Vue 4 + Vite
migration: 17,650 lines across 69 templates, 3,145 a-* component
instances across 63 files, with per-pattern counts and file lists.

Key findings:
- No Vue filters anywhere — dodges a major Vue 3 breaking change
- 358 v-model uses; AD-Vue 4 absorbs most, custom components don't
- 233 <template slot="X"> usages must become <template #X>
- 49 scopedSlots: { ... } column defs need new slots: { ... } shape
- a-icon is removed in AD-Vue 4 — every icon must be imported

Establishes the 8-phase order; Phase 2 (Vite toolchain) is next.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* build(frontend): Phase 2 — scaffold Vite + Vue 3 + AD-Vue 4

Adds a frontend/ directory that lives alongside the legacy web/html/
Vue 2 templates during the migration. Vite builds into ../web/dist/
so the Go binary will be able to embed the result via embed.FS once
Phase 4 starts moving real pages over.

- package.json pins Vue 3.5, Ant Design Vue 4.2, Vite 6, vue-i18n 10
- vite.config.js: dev server on :5173 with API proxy to the Go panel
  on :2053; build output to ../web/dist/
- src/App.vue is currently a smoke-test placeholder — delete once the
  first real page (login) lands in Phase 4
- node_modules and dist are already ignored at repo root

To verify locally:
  cd frontend && npm install && npm run dev

Pages will be migrated one at a time on the vue3-migration branch.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(frontend): Phase 3 — port utils, models, axios, websocket as ES modules

Ports the framework-agnostic JS from web/assets/js/ into frontend/src/
so Vue 3 pages can import what they need without relying on script-tag
globals.

- web/assets/js/util/index.js (927 lines, 21 classes) →
  frontend/src/utils/legacy.js + a barrel at utils/index.js. All
  classes are now named exports.
- Vue.prototype.$message in HttpUtil → direct import of `message`
  from ant-design-vue (Vue 3 has no Vue.prototype).
- RandomUtil.randomShadowsocksPassword previously defaulted to
  SSMethods.BLAKE3_AES_256_GCM from inbound.js, creating a circular
  import. Replaced with the literal string default.
- MediaQueryMixin (Vue 2 mixin) removed. Replaced by
  composables/useMediaQuery.js — Vue 3 composable returning reactive
  `isMobile`.
- axios-init.js wrapped as setupAxios(); Qs global → npm `qs`.
- websocket.js exported as WebSocketClient class; the implicit
  window.wsClient global is gone — pages instantiate it themselves.
- model/{inbound,outbound,dbinbound,setting,reality_targets}.js
  copied with `export` added on every top-level declaration. Imports
  between models and utils are wired up explicitly.
- subscription.js deferred to Phase 5 (it's a Vue 2 mount, not a util).
- App.vue smoke test exercises SizeFormatter / RandomUtil / Wireguard /
  useMediaQuery so the user can verify Phase 3 with `npm run dev`.

Run `cd frontend && npm install && npm run dev` — qs was added so a
fresh install is required.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 4 — port login.html to Vue 3 + AD-Vue 4 + Vite 8

First real page in the new toolchain. Multi-page Vite: each migrated
page is its own entry. login.html now lives at frontend/login.html with
a thin entrypoint at frontend/src/login.js mounting LoginPage.vue.

Vite 6 → Vite 8.0.11 (per user request). Requires Node 20.19+ or 22.12+.
@vitejs/plugin-vue bumped to ^6.0.6 (peers vite ^8). Ant Design Vue
stays on 4.2.6 — there is no AD-Vue 6.

Vue 2 → Vue 3 / AD-Vue 1 → AD-Vue 4 syntax changes hit on this page:
- new Vue({ el, delimiters, data, methods }) → createApp + <script setup>
- mounted() → onMounted()
- <template slot="X"> → <template #X>
- <a-icon slot="prefix" type="user"> → <template #prefix><UserOutlined />
  </template> with explicit @ant-design/icons-vue imports
- v-model.trim → v-model:value (AD-Vue 4 uses named v-model on inputs)

Three legacy features deferred so Phase 4 stays small:
- i18n (Phase 7 wires up vue-i18n)
- theme switcher (custom component pending Phase 5)
- headline word-cycle animation (purely aesthetic)

Run `cd frontend && npm install && npm run dev`, open
http://localhost:5173/login.html. With Go panel running on :2053 the
form submits real credentials via the configured proxy.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5a — theme system + Vite 8 + vue-i18n 11

Bumps Vite to 8.0.11 (npm install picked up 6.4.2 from the stale
lockfile; clean install resolves the new constraint). Bumps vue-i18n
to 11.1.4 since v10 was just EOL'd.

Migrates aThemeSwitch.html — the two-flavor theme picker + global
themeSwitcher object — into:

- composables/useTheme.js: single reactive `theme` state with
  toggleTheme / toggleUltra. Boot side-effect applies the stored theme
  to <body>/<html> before Vue renders; watchEffect persists changes
  back to localStorage.
- components/ThemeSwitch.vue: full menu version for the main panel.
- components/ThemeSwitchLogin.vue: login-popover version.

AD-Vue 1 → 4 changes hit on this component:
- <a-icon type="bulb" :theme="filled|outlined"> dropped — replaced by
  explicit BulbFilled / BulbOutlined imports from
  @ant-design/icons-vue, swapped via <component :is="BulbIcon">
- Vue.component('a-theme-switch', { ... }) global registration → SFC
  + per-page import
- this.$message.config(...) (Vue 2 instance method) → message.config(...)
  imported from ant-design-vue, called once in login.js at boot

Login page now surfaces a settings button → popover → theme picker.

Known gap: web/assets/css/custom.min.css isn't yet imported into the
new bundle, so toggling dark mode currently only re-themes AD-Vue's
own components, not the panel chrome. The body class is still toggled
so behavior is correct; visual fidelity returns when custom.css is
ported or directly imported.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5b — port four shared components to Vue 3

CustomStatistic.vue and SettingListItem.vue are mechanical
Vue.component → SFC ports.

AppSidebar.vue: AD-Vue 4 dropped <a-icon :type="dynamic">, so the
five sidebar icons (dashboard/user/setting/tool/logout) live in a
name→component map and render via <component :is>. The legacy
<a-drawer slot="handle"> hack is replaced with a sibling fixed-
position toggle button. Tab paths take basePath/requestUri as
props instead of pulling them from Go template scope.

TableSortable.vue: the biggest Vue 3 rewrite of this phase.

  - $listeners is gone — replaced by inheritAttrs: false +
    explicit attrs forwarding
  - scopedSlots: this.$scopedSlots collapsed into Vue 3's unified
    slots object — just iterate Object.keys(this.slots) and forward
  - Vue 2 h(tag, { props, on, scopedSlots }, children) →
    Vue 3 h(tag, { ...props, ...on }, slotsObject)
  - 'a-table' string → resolveComponent('a-table') so app.use(Antd)
    registration is honored
  - inject: ['sortable'] (Options API) → inject('sortable', null)
    (Composition API) inside the trigger child
  - beforeDestroy → beforeUnmount
  - customRow's return shape flattened (no nested props/on/class)

Two intentional skips, documented in the migration doc:

  - aClientTable.html — slot fragments, not a component. Migrates
    inline with inbounds.html (new Phase 5f).
  - aPersianDatepicker.html — wraps a Persian-only third-party
    lib; defer until settings.html lands.

Build verified with vite 8.0.11.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): anchor Vite dev proxy so /login.html isn't forwarded

The /login proxy entry was matching any path starting with /login —
including /login.html, which Vite is supposed to serve itself. Without
the Go backend running, this caused ECONNREFUSED noise on every page
load.

Switched to regex patterns anchored with ^...$ so only the bare backend
paths (/login, /logout, /getTwoFactorEnable) and explicit sub-routes
(/panel/*, /server/*) get proxied. Static .html files Vite serves
directly are no longer matched.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): real dark mode + silence dev proxy ECONNREFUSED noise

Two issues from running login.html against no Go backend:

1. Dark mode toggled the body class but didn't actually re-theme any
   AD-Vue components. The legacy panel relied on custom.min.css which
   we haven't ported. AD-Vue 4 ships its own dark algorithm — wrap
   LoginPage in <a-config-provider :theme="{ algorithm }"> driven by
   our useTheme state, and AD-Vue restyles every component for free.
   Page chrome (background, card, title) gets explicit .is-dark CSS
   since the algorithm only covers AD-Vue components.

2. Vite logged every failed proxy attempt loudly. When the Go panel
   isn't running locally that's pure noise. Added a configure()
   callback that swallows ECONNREFUSED specifically; real errors
   (timeouts, 5xx, anything else) still surface.

Both fixes are dev-experience only — production build is unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): use legacy panel palette for login page dark mode

Earlier dark mode used invented colors (#141a26 page bg, #1f2937 card)
that didn't match the rest of the panel. Replaced with the actual
values from web/assets/css/custom.min.css:

  light          dark             ultra-dark
  bg #c7ebe2     bg #222d42       bg #0f2d32
  card #fff      card #151f31     card #0c0e12
  title #008771  title #fff/.92   title #fff/.92

Drove everything off CSS custom properties on .login-app so the
.is-dark / .is-ultra class swap is a few var overrides instead of
duplicating selectors. Also restored the legacy card metrics
(2rem radius, 4rem 3rem padding, 2rem title) so the new page
matches the old panel's geometry, not just its colors.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): match legacy wave layout + recolor for dark mode

The wave SVG had inline fill="#c7ebe2" (mint) on the bottom wave, so
in dark/ultra-dark mode it rendered as a pale-white blob against the
dark page. Stripped the inline fills, drove them off CSS variables
that swap with .is-dark / .is-ultra:

  light:      green tints + #c7ebe2 (mint) on the bottom wave
  dark:       #222d42 across all four waves
  ultra-dark: #0f2d32

The wave was also positioned wrong — anchored to the top 200px of
the viewport with absolute positioning. Restored the legacy layout:
  - .waves-header is fixed to the top of the viewport with z-index -1
    so the form floats over it
  - .waves-inner-header pushes the wave SVG down to ~50vh with a
    50vh-tall solid block of the page color
  - .waves SVG itself is 15vh tall, sitting at the bottom of that block

Net effect: top half is solid-colored, then a wavy edge transitions
into the rest of the page, with the form centered on top — matching
the legacy panel exactly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): bring wave-header to front so the wave actually shows

Two layering bugs were hiding the wave entirely:

1. .ant-layout-content had background: var(--bg-page) which painted an
   opaque rectangle covering the full content area — including the
   fixed wave-header behind it. Made the layout/content transparent
   and moved the bg paint up to .login-app (the outer ant-layout).

2. .waves-header had z-index: -1 which on its own was fine, but with
   .ant-layout-content opaque on top it was doubly buried. Promoted
   the wave-header to z-index: 0 and gave the form .login-row
   z-index: 1, so the form sits above the wave and the wave sits
   above the page-bg.

Also set --bg-page to the legacy mint (#c7ebe2) for light mode so the
bottom half of the page below the wave matches the legacy panel
(was white). Dark mode stays at the surface-100/login-wave palette.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): match legacy wave animation timings + dark page bg

Two reasons the bottom wave looked static in dark/ultra-dark:

1. Animation durations were 7s/10s/13s/20s. Legacy uses 4s/7s/10s/13s.
   The 20s on the bottom wave was so slow that against the low dark-
   mode contrast it read as motionless. Restored the legacy timings.

2. --bg-page in dark mode was #151f31 (card color / surface-100), but
   the legacy .under uses surface-200 (#222d42) — that's the color of
   the bottom half of the page, the same as the wave fill, so the
   wave appears to flow into the page rather than meeting a hard edge.
   Now it does.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): restore Hello/Welcome headline cycle on login

Earlier I deferred the legacy headline word-cycling animation as
"purely aesthetic". Restored it: the title now alternates between
'Hello' and 'Welcome' every 2 seconds, matching the legacy panel.

The legacy implementation toggled .is-visible / .is-hidden classes on
two <b> elements via setTimeout chains and DOM querying. Replaced
with a reactive ref + Vue 3 <Transition mode="out-in"> so the fade
between words is declarative — no manual DOM manipulation, and the
interval is properly cleaned up in onBeforeUnmount.

The earlier "Welcome to 3x-ui" string was wrong on two counts: it
should be just "Welcome", and it should be one of two cycling words
with "Hello" preceding it.

Ultra-dark palette already matched legacy after the prior wave timing
fix; no additional changes needed there beyond the animation speeds
that now also apply to ultra-dark via the shared CSS rules.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): correct dark login bg + give ultra-dark wave real contrast

Two related fixes:

1. Default-dark wave-header bg was wrong. I had #0a2227, but that's
   the *ultra-dark* override; default dark uses --dark-color-background
   = #0a1222. Now the dark-mode top half is the legacy purple-blue
   instead of teal.

2. Ultra-dark wave fill is intentionally near-identical to its bg in
   the legacy palette (#0f2d32 vs #0a2227, ~5/11/11 RGB delta), which
   makes the wave look static even though the animation is running.
   Bumped --wave-fill / --wave-fill-bottom to #1f4d52 in ultra-dark
   only — far enough above the bg that the motion reads, while
   staying within the same teal hue family.

Also corrected ultra-dark --bg-page back to #0f2d32 (was briefly
#0c0e12, which is the card color, not the page color).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): drop ultra-dark bottom-wave seam line

Last fix made the wave fill #1f4d52 in ultra-dark for both top-three
waves and the bottom wave, which gave visible motion but exposed a
hard horizontal line where the bottom wave's flat lower edge met the
page bg (#0f2d32). The user noticed it as "the wave at the bottom
not moving its like a line" — they were seeing the SVG's clipped
bottom edge, not the wave itself.

Solution: only the top three waves get the brighter fill (those carry
the visible motion). The bottom wave reverts to #0f2d32 = --bg-page,
so its flat bottom edge merges seamlessly into the page below. Net
effect: motion is still visible (from waves 2 and 3), and there's no
seam line at the bottom of the SVG.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-i — index.html dashboard shell

Replaces the smoke-test App.vue with a real IndexPage shell so the
/index.html route now boots the actual dashboard layout in Vue 3:

- a-config-provider drives AD-Vue 4's dark algorithm from useTheme
  (same pattern as LoginPage)
- AppSidebar (Phase 5b component) is wired in with basePath +
  requestUri props
- a-spin loading state with placeholder card while we build out the
  rest of the page
- Page palette mirrors the legacy: light #f0f2f5, dark #0a1222
  (--dark-color-background), ultra-dark #21242a

The 1,805-line legacy index.html is too big for one commit. Split
into five sub-phases on the todo list: ii) status cards + /server/status
polling, iii) xray status card, iv) logs/backup/panel-update modals,
v) custom-geo section.

frontend/src/App.vue and frontend/src/main.js (smoke-test scaffold)
are removed — both purposes now served by IndexPage and index.js.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-ii — live status cards on the dashboard

Adds the CPU / memory / swap / disk dashboard cards to IndexPage,
backed by a useStatus() composable that polls /panel/api/server/status
every 2 s and a Status / CurTotal model ported from the legacy inline
classes in index.html.

- models/status.js — Status & CurTotal classes (CurTotal exposes
  reactive .percent and .color computed-style getters; Status maps
  the API payload + xray state to color/message strings)
- composables/useStatus.js — 2s polling with shallowRef so each fetch
  swaps the whole Status object atomically. WebSocket integration
  intentionally deferred — the legacy panel falls back to this same
  2s polling when its websocket drops, so we ship the proven path
  first and add WS on top in a later sub-phase.
- pages/index/StatusCard.vue — four a-progress dashboard widgets in
  a 2x2 grid (mobile collapses to a 1x4). CPU widget exposes a
  history button; the modal it opens is part of 5c-iv.
- IndexPage now consumes both, plus useMediaQuery so the layout
  responds to viewport changes.

AD-Vue 4 changes: <a-icon type="area-chart"|"history"> dropped in
favor of explicit AreaChartOutlined / HistoryOutlined imports.
<a-tooltip slot="title"> → <template #title>.

i18n strings still hardcoded English (Phase 7 wires up vue-i18n).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-iii — xray status card + stop/restart controls

XrayStatusCard.vue renders the right-hand card on the dashboard:

- Title with mobile-only version tag (matches the legacy collapse)
- Animated badge for the running/stop/error states. The pulsing dot
  comes from xray-pulse keyframes (renamed from runningAnimation in
  legacy custom.min.css). Color rings on the badge use the legacy's
  per-state border-color overrides on .ant-badge-status-processing.
- Error state replaces the badge with a popover that surfaces the
  multi-line errorMsg + a logs shortcut.
- Action row at the bottom: optional logs (when ipLimitEnable),
  stop, restart, and version switch.

IndexPage now wires:
- POST /panel/api/server/stopXrayService and /restartXrayService,
  followed by a refresh() so the status card reflects the new state
  without waiting for the next poll tick
- POST /panel/setting/defaultSettings to read ipLimitEnable
- Stub handlers for the panel-logs / xray-logs / version-switch /
  cpu-history modals — those land in 5c-iv

AD-Vue 4 changes hit on this card:
- <a-icon type="bars|poweroff|reload|tool"> → explicit
  BarsOutlined / PoweroffOutlined / ReloadOutlined / ToolOutlined
- <span slot="title|content"> → <template #title|#content>
- The .xray-*-animation classes ship as global <style> (not scoped)
  so they pierce AD-Vue's internal .ant-badge-status-* DOM.

i18n still hardcoded English; Phase 7 wires vue-i18n.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-iv (a) — panel update / logs / backup modals

Adds three of the six dashboard modals plus a Quick Actions card
that surfaces them. The remaining three (xray logs, version picker,
CPU history sparkline) ship in 5c-iv-b.

- PanelUpdateModal.vue — current vs latest version, "update now"
  button. Confirm dialog → POST /panel/api/server/updatePanel,
  then poll /server/status for up to 90s until the new panel
  answers, then reload.
- LogModal.vue — panel logs viewer. Filters: rows (10-500), level
  (debug/info/notice/warning/error), syslog toggle. Auto-fetches
  on open and on every filter change. Color-coded timestamps and
  levels via inline span styles. Download button writes the raw
  log to x-ui.log via FileManager.downloadTextFile.
- BackupModal.vue — db export (window.location to /getDb) and
  import (FormData upload to /importDB, then panel restart + reload).
- Quick Actions card surfaces Logs / Backup / Update buttons and
  shows an orange update badge (extra slot) when an update is
  available.

Modal-busy pattern: long-running operations (update, import) emit
a `busy` event with a tip; IndexPage flips its a-spin overlay so the
user sees a loading message while the panel is restarting.

AD-Vue 4 changes:
- v-model on <a-modal> renamed to v-model:open
- v-model on <a-input>/<a-select>/<a-checkbox> uses the named
  v-model:value / v-model:checked pattern
- <a-icon type="..."> dropped — explicit Ant icon imports
  (BarsOutlined, CloudServerOutlined, CloudDownloadOutlined,
  DownloadOutlined, UploadOutlined, SyncOutlined)
- Modal.confirm() replaces this.$confirm() since setup() has no `this`

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-iv (b) — cpu-history / xray-logs / xray-version modals

Wires up the three remaining dashboard buttons that were stubbed in
5c-iv (a): the CPU history button on StatusCard, the xray-logs button
in XrayStatusCard's error popover and ipLimitEnable action, and the
"Switch xray" button in XrayStatusCard's action footer.

- Sparkline.vue: shared SVG line chart (composition-API port of the
  inline Vue 2 component). Per-instance gradient id avoids defs
  collisions between sparklines on the same page.
- CpuHistoryModal.vue: bucket dropdown (2m/30m/1h/2h/3h/5h) drives
  GET /panel/api/server/cpuHistory/{bucket}; renders via Sparkline.
- XrayLogModal.vue: rows + filter + direct/blocked/proxy checkboxes;
  POST /panel/api/server/xraylogs/{rows} returns access-log entries
  rendered as a colored HTML table; download button serializes to text.
- VersionModal.vue: collapse with Xray panel (radio list of versions
  from getXrayVersion, install via installXray/{version}) and Geofiles
  panel (per-file reload + Update all). CustomGeo collapse panel is
  Phase 5c-v.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5c-v — custom-geo section in VersionModal

Adds the third collapse panel ("Custom geo") that lets users register
external geosite/geoip files referenced by routing rules via
ext:<filename>:tag. Backend endpoints are unchanged.

- CustomGeoSection.vue: bordered table over /panel/api/custom-geo/list
  with per-row edit, download (refetch), and delete actions, plus an
  Add button and Update-all. Lazy-loads the list when the parent
  collapse opens this panel — closed panels don't fetch.
- CustomGeoFormModal.vue: shared add/edit form with the same alias
  regex (^[a-z0-9_-]+$) and URL validation as legacy. Type and alias
  are immutable when editing — backend rejects changes anyway.
- ext:<filename>:tag value is click-to-copy via ClipboardManager.
- Relative time is computed inline (no moment dep); tooltip shows the
  absolute timestamp.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-i — settings page shell + dirty tracking

Adds the settings entry as a new Vite multi-page input. Lays down the
shared page chrome (sidebar, save bar, restart, security alert) and the
AllSetting fetch/dirty-poll lifecycle so 5d-ii through 5d-vi can drop
in tab partials without re-implementing it.

- settings.html + src/settings.js: third Vite entry; mounts SettingsPage.
- SettingsPage.vue: page chrome with the legacy two-button save/restart
  bar, conf-alerts banner, and 5 a-tabs (4 always-visible + the formats
  tab gated on subJsonEnable || subClashEnable). Each tab body is an
  a-empty placeholder until 5d-ii…vi fill them in.
- useAllSetting.js composable: POST /panel/setting/all on mount, mirrors
  the legacy 1s busy-loop dirty check via setInterval, and exposes
  fetchAll/saveAll. saveDisabled flips off as soon as the user diverges
  from the server snapshot.
- restartPanel rebuilds the URL (host/port/scheme/base path) from the
  saved settings so users land on the new endpoint after a port or
  cert change.
- models/setting.js: adopts the @/utils alias and a leading file-level
  doc — semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-ii — settings General tab

Ports the panel/general partial (the largest single tab) — six
collapse panels: General, Notifications, Certificates, External
traffic webhook, Date and time, LDAP.

- GeneralTab.vue receives the reactive AllSetting via props and binds
  fields directly with v-model:value; SettingsPage stays the sole
  fetch/save owner.
- remarkModel/remarkSeparator surfaced as computed v-models that
  read+write the underlying single-string field (legacy stores them
  packed as <separator><orderedKeys>, e.g. "-ieo").
- LDAP inbound-tags select binds to a CSV ↔ array computed; inbound
  options come from /panel/api/inbounds/list on mount.
- Language select stays cookie-based via LanguageManager and reloads
  on change — same UX as legacy.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-iii — settings Security tab + 2FA modal

Ports the panel/security partial: change-credentials form and 2FA
toggle. The 2FA modal is a new shared component since enabling 2FA,
disabling 2FA, and changing credentials all funnel through it with
slightly different copy.

- TwoFactorModal.vue: 'set' flow renders a QR code + manual key + a
  6-digit verifier; 'confirm' flow renders just the verifier. The
  parent passes a confirm(success) callback that fires only when the
  entered code matches the live TOTP value (otpauth lib).
- SecurityTab.vue: holds the local user form (oldUsername/oldPassword/
  new*), POSTs /panel/setting/updateUser, and on success force-redirects
  to logout. When 2FA is on, the credentials change goes through the
  confirm-modal first.
- toggleTwoFactor leaves the switch read-only (the v-bound :checked
  matches AllSetting) and only flips after the modal succeeds, so
  cancelling out leaves state unchanged.
- Adds otpauth ^9.5.1 dep (qrious was already present).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-iv — settings Telegram tab

Ports the panel/telegram partial: bot enable/token/chatId/lang in the
General panel, schedule/backup/login/CPU-threshold in Notifications,
and proxy/API-server overrides in the third panel. All bindings live
on the shared AllSetting reactive — no fetch/save logic in this tab.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-v — settings Subscription general tab

Ports the subscription/general partial — four collapse panels covering
the master enable switches, presentation/template fields, certs, and
update interval.

- Sub path goes through a strip-on-input + normalize-on-blur computed:
  legacy stripped `:` and `*` and ensured the value starts and ends
  with a single `/` — same here.
- Both `subEnableRouting` and the announce/profile/title/support URLs
  are bound directly on AllSetting.
- The "Subscription URI override" placeholder mirrors the legacy
  pattern for the manual full-URL form.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5d-vi — settings Subscription formats tab

Ports the subscription/json partial — paths/URIs for the JSON and
Clash formats plus the four packed-JSON sub-fields: fragment, noises,
mux, and direct routing rules.

- subJsonFragment / subJsonMux / subJsonNoises / subJsonRules are each
  a JSON string on the wire; the tab exposes their fields as computed
  v-models that read+write the underlying JSON. Toggling a top-level
  switch off resets the field to "" (matches legacy semantics).
- Direct routing rules surface the IP and domain entries of the seed
  rule array as multi-select tag inputs; setting/removing tags
  edits the rules array in place rather than rebuilding it from
  scratch, so manually-added rules are preserved.
- Tab is gated on subJsonEnable || subClashEnable in the parent (only
  rendered when the user actually opted into one of those formats).

This closes Phase 5d — full settings page parity with the legacy panel
across all five tabs.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): route /panel/<route> to migrated pages in dev

The sidebar links to production-style URLs like /panel/settings, but
in dev that gets proxied to the legacy Go template — which fails
because we haven't loaded the legacy asset chain. Add a proxy bypass
so /panel and /panel/settings are served from index.html / settings.html
on the Vite dev server itself. Unmigrated routes (inbounds, xray)
still proxy to Go.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(csrf): expose token endpoint for SPA pages and fetch it from axios

The legacy panel pages got their CSRF token from a <meta name="csrf-token">
tag rendered by Go. SPA pages built by Vite don't have that, so every
unsafe (POST/PUT/DELETE) request from them was hitting CSRFMiddleware
with no token and getting 403 — visible as the settings page being
stuck on "Loading…" because POST /panel/setting/all failed.

- web/controller/xui.go: GET /panel/csrf-token returns the session
  token. Lives under the xui group so checkLogin still gates it; the
  CSRFMiddleware on the same group is a no-op for GET.
- frontend/src/api/axios-init.js: cache the token at module scope and
  lazy-fetch it when a non-safe request needs one. Seed from the meta
  tag first when present (legacy compat). On a 403 response, drop the
  cache and retry once — handles the case where a server restart
  rotated the token after the SPA loaded.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): keep sidebar links absolute when basePath is empty

The dashboard sidebar built tab keys as basePath + 'panel/...'. In dev
the window-injected basePath is '' so the resulting key was a relative
path like 'panel/settings'. When the browser resolved that against the
current /panel/settings URL it produced /panel/panel/settings — visible
as broken navigation between Dashboard and Settings.

Force a leading slash so the keys are always absolute regardless of
whether the host injected a basePath.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-i — inbounds page shell + list fetch

Adds the inbounds entry as a fourth Vite multi-page input and wires
/panel/inbounds through the dev proxy bypass. Lays down the page
chrome (sidebar, summary statistics card, refresh button) and the
fetch lifecycle composable so 5f-ii onward can drop in the table
columns and the modals without re-implementing it.

- inbounds.html + src/inbounds.js: fourth Vite entry; mounts InboundsPage.
- InboundsPage.vue: sidebar + summary card (totals over up/down,
  all-time, inbound count, client tags) + a basic table with enable/
  remark/port/protocol/traffic/expiry columns. Row actions, popovers,
  search/filter, auto-refresh, and the WebSocket delta path are all
  deferred to subsequent 5f subphases.
- useInbounds.js composable: GET /panel/api/inbounds/list +
  POST /panel/api/inbounds/onlines + POST /panel/api/inbounds/lastOnline +
  POST /panel/setting/defaultSettings, then computes the
  per-inbound clientCount roll-ups (active/deactive/depleted/expiring/
  online/comments) the table popovers consume.
- models/dbinbound.js + models/inbound.js: switched the legacy-utils
  import to the @/utils alias for consistency with the rest of the
  app. Semantics unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-ii — inbound list table + search/filter + auto-refresh

Fleshes out the inbound list with the full column set, search & filter
toolbar, row enable toggle wired to /panel/api/inbounds/setEnable/:id,
and a per-row action dropdown that emits events the parent will route
to modals as those land in 5f-iii through 5f-vii.

- InboundList.vue (new): toolbar (Add inbound + General actions
  dropdown + Refresh + auto-refresh popover), search-or-filter switch
  with the legacy radio buttons (Active/Disabled/Depleted/Depleting/
  Online), and a a-table with desktop and mobile column variants.
  Cells use AD-Vue 4's #bodyCell slot — protocol/clients/traffic/
  allTime/expiry/info cells render the same popovers and tags as
  legacy. Row enable switch is optimistic with rollback on POST
  failure.
- visibleInbounds computed mirrors the legacy search and filter
  projection: deep search through dbInbound + clients, or filter
  reduces inbound.settings.clients to the selected bucket so the
  table only shows matching client rows.
- Auto-refresh interval is read/written to localStorage with the
  same keys (`isRefreshEnabled`, `refreshInterval`) as the legacy
  panel. WebSocket delta updates are still deferred.
- Action menu emits event payloads {key, dbInbound}; the parent
  currently shows a "coming in later 5f subphase" toast for each.
  Modals (edit/qr/clone/delete/reset/info/clients) land in
  5f-iii through 5f-vii.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(inbounds): wrap popover-table rows in <tbody>

Vue's template compiler warned that <tr> can't be a direct child of
<table> per the HTML spec; the browser silently inserts a <tbody>
wrapper but Vue's SSR/hydration path doesn't, which can cause
hydration mismatches. Add explicit <tbody> in both popover tables
(traffic + mobile-info).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-iii — inbound add/edit modal + delete/clone/reset

Wires up the inbound CRUD flows. The protocol-specific and transport-
specific forms are still ahead in 5f-iii-b — for now the modal exposes
those as JSON textareas so users can both edit existing inbounds without
losing settings and create new ones from default templates.

- InboundFormModal.vue: tabbed modal with a full Basics tab (enable,
  remark, protocol, listen, port, total GB, traffic reset, expiry
  date) and three JSON-edit tabs (Settings, Stream, Sniffing). Add
  mode stamps a fresh template per protocol via
  Inbound.Settings.getSettings(protocol); changing the protocol in
  add mode restamps the JSON. Edit mode pretty-prints the existing
  JSON so the user sees the same fields they save back.
- POST /panel/api/inbounds/add or /panel/api/inbounds/update/:id on
  submit; on success the parent refreshes the list and the modal
  closes. Malformed JSON in any of the three textareas surfaces a
  message.error and aborts the save without losing user input.
- InboundsPage.vue: wires the row action menu to real handlers —
  edit (opens the modal in edit mode), delete, reset-traffic,
  clone, reset-clients, del-depleted-clients all go through
  Modal.confirm and refresh on success. General actions menu wires
  reset-inbounds / reset-clients / del-depleted-clients the same way.
  Remaining actions (qrcode/info/import/export/copyClients) still
  toast as "coming soon" — those land in 5f-iv and 5f-v.
- Adds dayjs ^1.11.20 dep for the a-date-picker v-model interop.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-iv — client add/edit + bulk-add modals

Wires per-inbound client management. Both flows go through the same
addClient/updateClient endpoints as legacy; the modals just funnel
the form state into the right shape (`{id, settings: '{"clients": [...]}'}`).

- ClientFormModal.vue: protocol-aware single-client editor — email/
  password/id/auth/security/flow/subId/tgId/comment/ipLimit/totalGB/
  expiry/renewal fields are shown/hidden per protocol like legacy.
  Edit mode displays the per-client traffic stats with a reset
  button; IP-limit log is read on click and clearable. Random
  helpers (sync icon next to each label) regenerate UUID/email/
  password/sub-id values.
- ClientBulkModal.vue: 1–500 clients in one POST, with the legacy
  five email-generation modes (Random / +Prefix / +Num / +Postfix /
  Pure-Prefix-Num-Postfix). Builds clients via the protocol-aware
  factory and concatenates their toString() output into a single
  settings.clients JSON array.
- InboundsPage.vue: opens both modals from the row action menu
  (`addClient` / `addBulkClient`). They both refresh the inbound list
  on success.
- Outstanding row actions still toast as "coming soon": qrcode,
  showInfo, copyClients, clipboard. Those land in 5f-v / 5f-vi.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-v — inbound info + QR-code modals

Wires the row "info" and "qrcode" actions and ports the legacy
inbound_info_modal end-to-end. The info modal handles every protocol
the legacy panel did:
  • multi-user (VMess/VLess/Trojan/SS-multi/Hysteria) — per-client
    table + share links + per-link QR;
  • SS single-user — share link + QR;
  • WireGuard — full peer table with downloadable peer-N.conf and a
    wg:// share link per peer;
  • Mixed/HTTP/Tunnel — connection-detail tables.

- QrPanel.vue: shared link card (header tag, copy button, optional
  download button, optional QR canvas, monospace footer with the
  raw value). Per-instance QRious instances are repainted on
  value/size change.
- InboundInfoModal.vue: full info modal. Subscription URL block keys
  off subSettings.subURI/subJsonURI; IP-log lazy-loads on open and
  surfaces refresh + clear; tg-id, last-online, depleted/enabled tags
  all match legacy.
- QrCodeModal.vue: lighter modal used for the row "qrcode" action on
  SS-single and WireGuard inbounds (just the QRs, no info table).
- InboundsPage.vue: wires both flows. checkFallback() reproduces the
  legacy logic — when an inbound listens on a unix-socket fallback
  (`@<name>`), the link generator is pointed at the root inbound that
  owns the listen address so QRs/links carry the public host:port +
  the right TLS state. Multi-client navigation (focusing a specific
  client's links) is deferred to 5f-vi where the per-inbound expand-
  row table will pass the email through.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-vi — per-inbound client expand-row table

Each multi-user inbound row in the list now expands to show its
client roster, mirroring the legacy aClientTable component.

- ClientRowTable.vue: inner a-table with full desktop column set
  (action icons / enable / online / client-with-status-dot / traffic
  with progress bar / all-time / expiry with reset cycle) and a
  collapsed mobile variant (single dropdown menu + popover info).
  Self-contained: stats are looked up via a per-inbound email->stats
  Map; per-client confirms (reset/delete) live on the row.
- The component emits typed events (edit/qrcode/info/reset-traffic/
  delete/toggle-enable) — InboundsPage routes them back to the
  existing client and info modals (with `findClientIndex` so the
  modal opens focused on the right client).
- InboundList.vue: hooks ClientRowTable into the a-table's
  expandedRowRender slot; row-class-name `hide-expand-icon` and a
  scoped CSS rule hide the chevron for non-multi-user inbounds
  (HTTP/Mixed/Tunnel/WireGuard/SS-single) so they keep looking flat.
- toggle-enable-client routes through updateClient with the same
  `{id, settings: '{"clients": [...]}'}` shape as the other modals,
  so backend parsing stays single-pathed.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-iii-b — replace inbound modal JSON textareas with structured forms

Rewrites InboundFormModal to look like the legacy panel: structured
forms for the common case, with a compact "Advanced (JSON)" fallback
for the rare bits we don't yet have UI for.

Tabs:
  • Basics — enable/remark/protocol/listen/port/total/trafficReset/expiry
  • Protocol — protocol-aware:
      VMess/VLess/Trojan/SS-multi/Hysteria in add mode embed an inline
        first-client form (email + ID/password/auth, security, flow,
        subId, comment, total GB, expiry);
      edit mode shows a clients-count summary table;
      VLess: decryption/encryption inputs;
      SS: method dropdown that re-randomizes password and propagates
        method change to the multi-user array (matches legacy
        SSMethodChange);
      HTTP/Mixed: accounts table with add/remove rows + Mixed
        auth/udp/ip toggles;
      Tunnel: address/port/network/followRedirect;
      WireGuard: secretKey/pubKey (regen via Wireguard.generateKeypair)
        + per-peer fields with PSK regen + allowedIPs add/remove +
        keepAlive.
  • Stream — only when canEnableStream(); transport selector with
      structured forms for TCP (proxy-protocol, http camouflage),
      WS (host/path/heartbeat/headers), gRPC (serviceName, multiMode),
      HTTPUpgrade (host/path). KCP/XHTTP fall back to the Advanced tab
      with an alert banner. Security selector with TLS (sni/alpn/
      fingerprint) and Reality (target/serverNames/keypair-gen via
      /panel/api/server/getNewX25519Cert / shortIds / fingerprint).
  • Sniffing — enabled/destOverride/metadataOnly/routeOnly/
      ipsExcluded/domainsExcluded as structured fields.
  • Advanced (JSON) — raw streamSettings + sniffing JSON for users
      reaching KCP/XHTTP/sockopt/finalmask/full TLS cert arrays. The
      stream JSON is auto-synced from the live model whenever the
      structured fields change.

State source of truth is a deeply-reactive Inbound + DBInbound pair
cloned on open; submit serializes via inbound.settings.toString() +
inbound.stream.toString() so the wire shape matches the legacy panel
byte-for-byte. streamNetworkChange semantics (clear flow when
TLS/Reality unavailable, reset finalmask.udp when not KCP) are
preserved.

Vision Seed for VLess + finer-grained TCP HTTP camouflage + the full
TLS cert/ECH editor will land in 5f-iii-c.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 5f-vii — shared text/prompt modals + remaining export/import wiring

Wires up the last batch of inbound row + general actions that were
toasting "coming soon": export-inbound-links, export-subs (per-inbound
and global), export-all-links, import-inbound, and the clipboard JSON
peek. Two small shared components back them — both can be reused by
the xray page later.

- TextModal.vue (shared): read-only multi-line viewer with a copy
  button and an optional download button when fileName is set.
  Replaces the legacy txtModal which the inbounds page used for every
  link export.
- PromptModal.vue (shared): generic title + input/textarea + confirm
  callback, with the legacy keybindings (Enter submits in single-line
  mode; Ctrl+S submits in textarea mode). Used here for import-inbound
  but also by xray-config edits in Phase 6.
- InboundsPage.vue: drops the toast stubs for `import`/`export`/`subs`
  on the general-actions menu and `export`/`subs`/`clipboard` on the
  per-row menu, routing each through openText / openPrompt + the
  appropriate model helper (genInboundLinks, etc.). The copyClients
  cross-inbound modal stays toast-stubbed — that's its own dedicated
  legacy modal worth its own commit.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-i — xray page scaffold + Advanced JSON tab

The fifth and last legacy page comes online. Tabs are scaffolded with
a-empty placeholders for the structured editors (Basics / Routing /
Outbounds / Balancers / DNS) so navigation is stable; the
Advanced (JSON) tab is fully functional and lets power users edit
the raw xraySetting tree exactly like the legacy CodeMirror pane.

- xray.html + src/xray.js: fifth Vite multi-page entry, mounted as
  XrayPage; vite.config.js routes /panel/xray and /panel/xray/ to it
  through the dev proxy bypass alongside the other pages.
- XrayPage.vue: page chrome with the Save / Restart-xray bar, restart-
  output popover (surfaces /panel/xray/getXrayResult content when
  startup fails), 6 a-tabs, and a textarea-backed Advanced JSON editor.
  CodeMirror is intentionally not pulled in — the textarea works for
  every modern browser and keeps the bundle slim while structured
  editors land in 6-ii through 6-v.
- useXraySetting.js composable: POST /panel/xray/ on mount, mirrors
  the settings-page 1s busy-loop dirty check for both xraySetting
  and outboundTestUrl, and exposes saveAll + restartXray. The dirty
  flag relies on string equality of the pretty-printed JSON, so
  reformat-only edits don't enable Save.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-ii — xray Basics tab structured editor

Replaces the placeholder on the Basics tab with a structured form for
the most-touched fields of the xray template — outbound + routing
strategy, log levels, traffic stat counters, and the "basic routing"
shortcuts (block torrent / IPs / domains, direct IPs / domains, IPv4
forced, WARP / NordVPN routing).

- useXraySetting.js: hoists a parsed `templateSettings` reactive
  alongside the JSON string, with two cooperating watches that keep
  them in sync. Editing structured fields stringifies into xraySetting
  for the dirty-poll + Advanced JSON tab; editing the JSON re-parses
  into templateSettings only when valid, so structured tabs stay
  readable mid-edit.
- BasicsTab.vue: collapse panels mirror the legacy partial — General,
  Statistics, Logs, Basic routing. Every input is a computed v-model
  reading/writing into templateSettings; the routing-rule shortcuts
  funnel through ruleGetter/ruleSetter which match the legacy
  templateRuleGetter/templateRuleSetter behavior (replace-first,
  drop-duplicates, pop-the-rule-when-empty). Direct/IPv4 setters
  also call syncOutbound() to provision/prune the matching outbound.
- XrayPage.vue: imports BasicsTab + derives `warpExist`/`nordExist`
  from the parsed templateSettings. WARP/NordVPN provisioning modals
  are still placeholders that toast — those land in 6-v with the
  routing/outbound editors.

Default tab flips back to Basics so users land on the structured
editor.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-iii — xray Routing tab + rule modal

Replaces the Routing tab placeholder with a full editor for
templateSettings.routing.rules:

- RoutingTab.vue: a-table over the parsed rules with the legacy six-
  column layout (action / source / network / destination / inbound /
  outbound) and the same "lead value + N more" pill renderer for
  multi-value criteria. Mobile drops source/network/destination for
  readability. Per-row dropdown handles edit / move-up / move-down /
  delete; the array-mutation reordering replaces the legacy jQuery
  Sortable drag handle without pulling in a sortable lib.
- RuleFormModal.vue: full form mirroring xray_rule_modal.html —
  CSV inputs for sourceIP/sourcePort/vlessRoute/ip/domain/user/port,
  Network select, Protocol multi-select, Attrs key/value pairs,
  inbound-tag multi-select sourced from
  templateSettings.inbounds + parent inboundTags + dnsTag,
  outbound-tag single-select sourced from templateSettings.outbounds
  + clientReverseTags, and balancerTag from
  templateSettings.routing.balancers. Submit serializes via the
  same shape the legacy `getResult` produces (CSV → array, drop
  empty fields).
- XrayPage.vue: imports RoutingTab and exposes inboundTags +
  clientReverseTags from useXraySetting so the modal can populate
  its tag pools.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-iv — xray Outbounds tab + outbound modal

Replaces the Outbounds tab placeholder with a full table + add/edit
flow. The 1.3k-line legacy outbound modal is condensed to a tabbed
modal with structured Basics fields (tag/protocol/sendThrough/domain
strategy) and JSON tabs for the protocol-specific settings + stream
trees — same approach the Inbound modal uses, and a power user can
still edit the same trees via the page-level Advanced (JSON) tab.

- useXraySetting.js: adds fetchOutboundsTraffic +
  resetOutboundsTraffic + testOutbound. Test states are tracked per
  outbound index so the row's Test button can show loading + the
  Test-result column can render the response delay / status / error.
- OutboundsTab.vue: full table (action / identity / address / traffic
  / test result / test) plus a card-list mobile variant with the
  same row dropdown (set-first / edit / move up/down / reset traffic
  / delete). outboundAddresses() reproduces the legacy
  findOutboundAddress logic so each protocol's host:port list is
  rendered consistently. Add/edit go through OutboundFormModal,
  delete goes through Modal.confirm, reset traffic posts to
  /panel/xray/resetOutboundsTraffic with the row's tag (or
  "-alltags-" from the toolbar).
- OutboundFormModal.vue: tag/protocol/sendThrough/domainStrategy on
  the Basics tab; settings + streamSettings as raw JSON on their
  respective tabs. Tag-collision check happens client-side before
  emitting; malformed JSON aborts the save with a message.error.
- XrayPage.vue: imports OutboundsTab and wires the test action to
  the composable's testOutbound helper.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-v — xray Balancers tab + DNS placeholder

Brings Balancers to full parity with the legacy panel and adds a
DNS tab placeholder that exposes the full dns/fakedns trees as JSON
so users can edit them without falling through to Advanced.

- BalancerFormModal.vue: tag (with duplicate-tag warning across
  other balancers), strategy (random/roundRobin/leastLoad/leastPing),
  selector tag-mode multi-select sourced from existing outbound
  tags + free-form additions, fallback. Disable-on-invalid is
  driven by the duplicateTag + emptySelector computed flags.
- BalancersTab.vue: empty state with a single "Add balancer" CTA;
  populated state shows the legacy 4-column table (action / tag /
  strategy / selector / fallback) with per-row edit + delete in a
  dropdown. On submit the wire shape preserves the
  `strategy: { type }` nesting only when the strategy is non-default,
  matching the legacy emit. Tag renames also chase across
  routing.rules.balancerTag references so existing rules don't dangle.
- DnsTab.vue: master enable switch + raw JSON for `dns` and
  `fakedns`. Legacy had a dedicated server-by-server editor + a
  fakedns row editor; both are big enough to deserve their own
  commits, and the JSON path supports every field today.

WARP / NordVPN provisioning modals still toast as "coming soon" —
those are third-party API integrations worth their own commits.
The xray page now has structured editors for Basics / Routing /
Outbounds / Balancers and JSON editors for DNS / Advanced — every
xray tab the legacy panel offered is functional.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(server): Phase 8 — cut HTML routes over to web/dist/

Production cutover. Every user-facing HTML route now serves the
Vue-3-built bundle from web/dist/ instead of rendering the legacy
Go template; the long-hashed Vite assets are served at /assets/ from
the same embedded filesystem. The legacy templates in web/html/ and
the legacy static tree in web/assets/ are kept on disk for now in
case a quick revert is needed, but nothing the binary serves
references them.

What changed:
- web.go: a new //go:embed dist/* feeds the controller package via
  a SetDistFS hand-off before controller construction. The static
  /assets/ route is rebound: in dev to web/dist/assets/ on disk so
  Vite's incremental rebuilds show up live; in prod to the embedded
  dist via wrapDistFS (rooted one level deeper than wrapAssetsFS).
- controller/dist.go: serveDistPage helper used by every HTML
  handler. Reads dist/<name> from the embedded FS and applies two
  transforms before sending:
    1. injects <script>window.__X_UI_BASE_PATH__="..."</script>
       just before </head> so AppSidebar links resolve under the
       panel's basePath.
    2. when basePath != "/", rewrites Vite's absolute /assets/ URLs
       to <basePath>assets/ so installs running under a custom URL
       prefix load the bundle where the static handler lives.
  HTML responses go out with no-cache so panel upgrades reach
  users on the next refresh; hashed JS/CSS stays cacheable.
- controller/index.go: IndexController.index now serves
  dist/login.html for logged-out callers (the redirect for logged-in
  users is unchanged).
- controller/xui.go: XUIController.{index,inbounds,settings,xraySettings}
  each become a one-line wrapper around serveDistPage.

Smoke checklist for the maintainer:
- run `cd frontend && npm run build` to refresh web/dist/ before
  building the Go binary (the embed snapshot is taken at compile
  time);
- visit /panel/, /panel/inbounds, /panel/settings, /panel/xray and
  confirm each loads its Vue page;
- log out and log back in to verify the login flow;
- confirm the sidebar links navigate correctly under your install's
  basePath;
- POST flows (e.g. saving settings) still need the CSRF token —
  that endpoint (/panel/csrf-token, added earlier) is unchanged.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 6-vi — WARP + NordVPN provisioning modals

Replaces the toast stubs on the Basics tab and Outbounds toolbar
with the legacy WARP + NordVPN provisioning flows. Both modals now
stage their wireguard outbounds back into templateSettings.outbounds
through the same event channels OutboundsTab uses, so the existing
add / reset / delete / refresh-traffic surface keeps working.

- WarpModal.vue: empty state shows a single Create button that
  generates a wireguard keypair locally (Wireguard.generateKeypair)
  and posts it to /panel/xray/warp/reg; populated state surfaces
  the access_token / device_id / license_key / private_key, lets
  the user upgrade to WARP+ via /panel/xray/warp/license, refreshes
  the account info from /panel/xray/warp/config (plan / quota /
  usage in human-readable bytes), and stages a wireguard outbound
  with the WARP-specific reserved-byte encoding pulled from
  client_id. Add / Reset / Delete go through events the parent
  routes back to templateSettings.outbounds.
- NordModal.vue: dual-tab login (NordVPN access token →
  /panel/xray/nord/reg, or paste a NordLynx private key →
  /panel/xray/nord/setKey). Once authenticated, country / city /
  server selectors fetch from /panel/xray/nord/{countries,servers},
  servers sort by load ascending, the lowest-load server in the
  current city auto-selects. Reset emits oldTag/newTag so the
  parent renames matching routing rules in place; logout emits a
  remove-routing-rules event with prefix `nord-` to purge any
  dangling references.
- XrayPage.vue: holds warpOpen / nordOpen flags, ensures the
  outbounds array exists before mutating it, and wires the modal
  events (add-outbound / reset-outbound / remove-outbound /
  remove-routing-rules) to in-place edits of templateSettings.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): Phase 7 — vue-i18n wired up + login page translated

Sets up vue-i18n on top of the panel's existing TOML translation
files. The Go side stays the source of truth — translators continue
to edit web/translation/*.toml; a sync script snapshots those files
into per-locale JSON the Vue bundle imports. The login page is
translated end-to-end as a worked example; remaining pages can be
converted incrementally without infrastructure churn.

What's in the box:
- scripts/sync-locales.mjs: small TOML→JSON converter that walks
  web/translation/*.toml and writes frontend/src/locales/<code>.json.
  Handles the narrow subset of TOML the panel uses (flat key/value
  pairs + dotted [section.subsection] heads). Wired as a `prebuild`
  + `predev` script so production builds always include the latest
  strings without a manual step.
- src/i18n/index.js: createI18n() in composition mode with all 13
  locales emitted as their own Vite chunks. The active locale (read
  from the same `lang` cookie LanguageManager has always managed)
  plus the en-US fallback are eagerly loaded; the rest are
  dynamically importable via a loadLocale(code) helper. This keeps
  the per-page bundle the user actually downloads small — only ~30
  KB of strings end up in the initial payload, vs ~220 KB if all
  13 were eager.
- All five page entries (index/login/settings/inbounds/xray) wire
  the i18n plugin into createApp via .use(i18n).
- LoginPage.vue: t(...) replaces hardcoded English on the username
  / password / 2FA placeholders, the submit button label, and the
  Settings popover title. The Hello/Welcome headline cycle stays
  hardcoded — those are stylistic, not labels.

The 'Hello'/'Welcome' cycle stays in English deliberately; the rest
of the migration's components still ship hardcoded English and will
be converted page by page in follow-up commits.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* i18n(frontend): translate page chrome — sidebar, save bars, tabs, summary cards

Replaces hardcoded English with t() calls in the components every
user sees on every page load. The translations themselves come from
the existing TOML files via the sync script — no new strings, no
new locale keys.

Per component:
- AppSidebar.vue: 5 menu titles (dashboard / inbounds / settings /
  xray / logout). Computed so the sidebar re-renders when the
  cookie-driven locale flips on reload.
- IndexPage.vue: Quick actions card title + Logs / Backup / Up-to-
  date / Update buttons.
- StatusCard.vue: CPU / Memory / Swap / Storage labels +
  logical-processors / frequency tooltips.
- XrayStatusCard.vue: card title + error popover header + Stop /
  Restart / Switch xray action labels (kept the v-prefix version
  string as-is — it's content, not a label).
- SettingsPage.vue: 5 tab titles + Save / Restart-panel buttons +
  unsaved-changes warning.
- XrayPage.vue: 6 tab titles + Save / Restart-xray buttons +
  unsaved-changes warning.
- InboundsPage.vue: 5 summary-stat card titles.
- InboundList.vue: 10 column titles (computed for live locale),
  Add inbound / General actions buttons + every dropdown menu item,
  search placeholder, filter radio labels, popover titles
  (disabled / depleted / depleting / online), traffic + info
  popover row labels.

Total: ~75 strings localised across 8 files. The remaining English
labels live in the per-tab settings forms, the form modals
(Inbound / Client / Outbound / Rule / Balancer / WARP / Nord), and
the per-row table cell helpers — all incremental work that doesn't
touch infrastructure.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* i18n(frontend): translate every remaining English string on the index page

Closes the index page's i18n coverage. Combined with the page-chrome
commit, every label users see on the dashboard is now sourced from
the TOML translation files.

Per file:
- IndexPage.vue: loading-spinner tip (initial + dynamic).
- BackupModal.vue: modal title, both list-item titles + descriptions
  ("Back up" / "Restore"), in-flight busy tips ("Importing database…"
  / "Restarting panel…").
- PanelUpdateModal.vue: modal title, update-available alert,
  current/latest version row labels, "Up to date" tag + label,
  primary action button. Modal.confirm now uses the translated
  panelUpdateDialog / panelUpdateDialogDesc with #version#
  substitution; success toast uses panelUpdateStartedPopover.
- LogModal.vue: title slot ("Logs"). The Debug/Info/Notice/Warning/
  Error log-level options stay literal — they're xray's wire values,
  not user-facing labels (matches the existing settings-page choice).
- XrayLogModal.vue: title + Filter label. Direct/Blocked/Proxy stay
  literal for the same reason.
- VersionModal.vue: modal title + xray-switch alert + per-file
  tooltip + "Update all" button + custom-geo collapse header. The
  Modal.confirm flows for switchXrayVersion + updateGeofile use
  translated dialog/desc with #version# / #filename# substitution.
- CpuHistoryModal.vue: title slot.
- CustomGeoSection.vue: routing-hint alert, Add / Update-all buttons,
  every column title (computed for live locale), copy/edit/download/
  delete tooltips, copy toast, delete-confirm modal, empty-state
  text.
- CustomGeoFormModal.vue: add/edit titles, OK/cancel labels, Type/
  Alias/URL field labels, alias placeholder, all three validation
  toasts.

Total: ~50 strings localised across 8 index-page files. The Hello /
Welcome login headline cycle and a handful of literal xray wire
values (Direct/Blocked/Proxy/log levels) are intentionally kept
hardcoded.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* i18n(frontend): Phase 7-c — translate settings, inbounds modals, xray tabs

Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): Phase 9 — restore index dashboard, fix login/CSRF, port legacy styles

- Index dashboard regains the 8 cards that were lost in the SPA port
  (3X-UI panel info, Operation Hours, System Load, Usage, Overall Speed,
  Total Data, IP Addresses, Connection Stats), plus a Config button that
  shows the live xray config.json. Version display falls back through
  panelUpdateInfo → window.__X_UI_CUR_VER__ → '?' so dev mode isn't blank.
- Xray config no longer hangs on load: useXraySetting surfaces failures
  instead of leaving a perpetual spinner, and the Vite dev proxy stops
  hijacking POST requests to migrated routes (only GETs get bypassed).
- Inbound page no longer throws __asyncLoader/emitsOptions errors —
  inbound.js was missing imports (NumberFormatter, SizeFormatter,
  Wireguard) and InboundList kept emitting after unmount.
- Login round-trip works after logout: a public /csrf-token endpoint
  bootstraps the SPA before authentication, axios caches the token
  module-level, and the dev 401 handler navigates to /login.html
  instead of reloading the dashboard into a redirect loop.
- legacy.css mirrors the legacy panel's surface/text variables so dark
  and ultra-dark themes match main; every SPA entry imports it.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): rebuild xray DNS section to match main branch

DnsTab now exposes every field the legacy panel did — top-level toggles
(tag, hosts, queryStrategy, disableCache/queryConcurrency, fallback
strategy, client subnet), the servers table with per-row strategy and
domain/expectIP/unexpectedIP overrides, and the Fake DNS pool. The new
DnsServerModal covers the full add/edit flow and collapses to a bare
string when the user only sets an address — matching the wire shape
the legacy form emits for plain DNS entries like "8.8.8.8".

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): rebuild xray outbound modal with structured per-protocol forms

Replaces the JSON textareas with the same shape the legacy panel uses:
all 11 outbound protocols (vmess/vless/trojan/shadowsocks/socks/http/
mixed/wireguard/tun/dns/loopback/blackhole/freedom) get dedicated
fields, every transport (TCP/KCP/WS/gRPC/HTTPUpgrade/XHTTP) gets its
own panel, and TLS/Reality/sockopt/Mux are configured through the same
controls as the inbound side. Brings the SPA outbound editor to parity
with main so users no longer have to drop into raw JSON.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): bring inbound modal to full parity with main branch

Switches the default protocol on add to VLESS, fixes a crash when adding
a Mixed account (the constructor is SocksAccount, not MixedAccount),
and fills in the fields the SPA was previously delegating to the
Advanced JSON tab:
- TLS: cipher suites, min/max version, reject SNI / disable system root /
  session resumption switches, the certificate array with per-row
  Path-or-Content toggle (Set Default pulls from /panel/setting/
  defaultSettings), One Time Loading, Usage / Build Chain, plus ECH
  key/config with a Get New ECH Cert button.
- Reality: xver, target/SNI sync icons (uses getRandomRealityTarget),
  max time diff, min/max client version, short IDs randomizer, SpiderX,
  mldsa65 seed/verify with Get New Seed.
- Stream: full structured forms for every transport — TCP HTTP
  camouflage gets its request/response editor, mKCP gets MTU/TTI/uplink/
  downlink/CWND/maxSendingWindow, WebSocket / gRPC (now with Authority) /
  HTTPUpgrade get headers + proxy-protocol toggles, XHTTP gets the
  full SplitHTTPConfig surface (mode-aware fields, padding obfs,
  session/sequence placement, uplink data, no-SSE).
- New External Proxy section and a structured Sockopt block (mark,
  TCP keepalive/timeout/clamp, fast open, MPTCP, penetrate, V6Only,
  domain strategy, congestion, TProxy, dialer/interface, trusted XFF).
- VLESS gets the legacy X25519 / ML-KEM-768 buttons that fetch fresh
  decryption/encryption blocks via /panel/api/server/getNewVlessEnc.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): add FinalMask UI (TCP/UDP masks + QUIC params) to inbound and outbound

Mirrors web/html/form/stream/stream_finalmask.html as a shared
FinalMaskForm component used by both modals — they share the same
StreamSettings shape (addTcpMask/addUdpMask/finalmask/enableQuicParams)
so a single template handles both. Surfaces:
- TCP masks for raw/tcp/httpupgrade/ws/grpc/xhttp networks: fragment,
  sudoku, and header-custom (with the 2D clients/servers groups, each
  row supporting array/str/hex/base64 packets and a randomize button
  for base64).
- UDP masks for hysteria protocol or kcp network: hysteria gets just
  salamander; kcp gets the full type list (mkcp variants, header-*,
  xdns/xicmp, header-custom with flat client/server lists, and noise).
  Switching to xdns shrinks the kcp MTU to 900 to match the legacy
  panel's behavior.
- QUIC Params for hysteria or xhttp: congestion (incl. brutal up/down
  fields), debug, UDP hop ports/interval, idle/keepalive timeouts,
  path-MTU discovery toggle, and the four receive-window tunables.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): remove duplicate Outbound test URL from xray Advanced tab

The Basics tab already exposes this field through BasicsTab —
duplicating it on the Advanced tab let two inputs race the same
ref and only added clutter.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): unify theming on vanilla AD-Vue light/dark/ultra-dark

The legacy panel CSS (custom.min.css ported as legacy.css) tinted every
non-primary button teal-green via .dark .ant-btn:not(.ant-btn-primary)
overrides while AD-Vue 4's darkAlgorithm kept primary buttons blue —
producing the mixed blue/green button look on dark mode. Drop legacy.css
entirely and let AD-Vue 4's algorithms own the palette.

Centralize antdThemeConfig in useTheme.js so every page resolves to the
same source of truth (light = defaultAlgorithm, dark = darkAlgorithm,
ultra-dark = darkAlgorithm + deeper colorBgBase/Layout/Container/
Elevated tokens). Each page's <a-config-provider> now imports the
shared computed instead of defining its own copy.

Drops the 67 KB legacy CSS chunk; per-page CSS bundles fall to ≤5.9 KB.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): restore computed import in Settings + Xray pages

When 5f1aba28 dropped the local antdThemeConfig computed (now shared
from useTheme), it also stripped `computed` from the import list — but
both pages still call computed() elsewhere (confAlerts, advanced-tab
helpers). Re-adds it.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): retheme dashboard gauges to AD-Vue blue and shrink them

- StatusCard's CPU/RAM/Swap/Storage dashboards rendered at AD-Vue's
  default 120px width which made the percent text balloon to ~36px.
  Drop to 90px (70px on mobile) so the gauge fits the rest of the card.
- The CurTotal.color thresholds still hardcoded the legacy teal/orange
  palette (#008771 / #f37b24 / #cf3c3c). Switch to AD-Vue's primary /
  warning / danger tokens (#1677ff / #faad14 / #ff4d4f) so the gauges
  match the rest of the panel under both light and dark themes.
- XrayStatusCard's running-animation badge ring also still pointed at
  the deleted --color-primary-100 var; hardcode the new primary blue.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* i18n: shorten backupTitle to "Backup & Restore" across all 13 locales

The backup modal header was the second-longest title in the dashboard
on every locale ("Database Backup & Restore" / "Резервне копіювання
та відновлення бази даних" / etc). Drop the "Database / Veritabanı /
数据库" qualifier — the modal already lives under the "Database"
column, so the shorter form reads cleaner on narrow viewports.

Updated both the .toml source-of-truth files and the synced .json
locales (re-running scripts/sync-locales.mjs).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* i18n: collapse two translation databases into a single web/translation/<lang>.json set

The Vue SPA had been reading from frontend/src/locales/*.json while the
Go binary still loaded web/translation/translate.*.toml — and a
sync-locales.mjs pre-build step kept the two in lockstep, with TOML as
the source of truth. Now that go-i18n v2.6.1 already flattens nested
JSON via recGetMessages/addChildMessages, both runtimes can share one
file per locale.

- Move the 13 nested-JSON locale files to web/translation/<lang>.json
  so they live alongside the Go //go:embed translation/* directive.
- Switch web/locale/locale.go from toml.Unmarshal to json.Unmarshal
  (and drop the pelletier/go-toml import — it's now indirect-only).
  Confirmed via a smoke test that pages.index.cpu, subscription.title,
  tgbot.commands.help, and menu.settings all resolve in en-US, fa-IR,
  ru-RU, and zh-CN.
- Repoint Vue's i18n loader at the new path (../../../web/translation/
  *.json glob) and drop the moved-here pathDelimiter comment that no
  longer applies.
- Delete the 13 legacy translate.*.toml files and the sync-locales.mjs
  script + its npm pre-script hooks (predev/prebuild/i18n:sync). The
  Telegram bot and subscription page still get their messages because
  they were reading the same MessageIDs the JSON files now produce.
- Update copilot-instructions.md so the next contributor knows where
  the canonical translation files live.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): redesign expand-row + retheme client visuals

When you expanded an inbound row, the nested <a-table> inside
ClientRowTable burst out of the parent's scroll-x box — its
.ant-spin-container ended up wider than the parent's narrow
.ant-table-cell, so the child looked oversized while the parent looked
squeezed. Replace the nested table with a CSS-grid layout that owns
its sizing, sits flush inside the expanded cell, and collapses to a
3-column layout on mobile (action menu, client identity, info popover).

While in there, fix three other client-row visuals:
- The Unicode infinity glyph (U+221E) renders as an "m"-shaped
  character in some system fonts (Windows Segoe UI in particular).
  Add a shared <InfinityIcon /> SVG component (legacy panel's path)
  and use it in ClientRowTable, InboundList, and InboundInfoModal —
  desktop and mobile cells.
- The "unlimited quota" traffic bar passed :percent="100" with no
  stroke-color, so AD-Vue auto-coloured it success-green. Pin it to
  the AD-Vue purple token (#722ed1) so it reads as the no-limit
  sentinel rather than another usage state.
- ColorUtils + the in-row statsExpColor still hardcoded the legacy
  teal/orange/red/purple palette (#008771 / #f37b24 / #cf3c3c /
  #7a316f). Map them onto AD-Vue 4's success/warning/danger/purple
  tokens (#52c41a / #faad14 / #ff4d4f / #722ed1) so badges, tags,
  and progress bars all match the rest of the panel.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): darken light-theme page bg so cards stand out

The light-theme --bg-page was #f0f2f5 — close enough to AD-Vue's #fff
card background that the cards faded into the page. Bump it to #e6e8ec
(a more visibly distinct gray) so cards lift cleanly off the surface.
Dark and ultra-dark stay where they were.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): shrink dashboard percent text and surface the unfinished arc

Two follow-up tweaks to the dashboard gauges:
- AD-Vue scales the percent text from the SVG, not from :width, so
  the 90px gauges still rendered the number at ~27px. Pin
  .ant-progress-text to 14px via :deep() and trim the gauge to 70px
  (60px on mobile) so the whole card stays compact.
- The default trail (rgba(0,0,0,0.06) / rgba(255,255,255,0.08)) was
  invisible on the light-theme card. Pass an explicit
  rgba(128,128,128,0.25) trail-color so the unfinished portion is
  visible under both light and dark themes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): migrate subpage.html to Vue 3 SPA

The subscription info page was the last page still rendered by Go
templates. Move it to the Vite multi-page setup so the whole panel
loads through one toolchain.

Frontend: SubPage.vue mounts at /sub/<id>?html=1 and reads window.__SUB_PAGE_DATA__
for the parsed view-model (traffic / quota / expiry + rendered share
links). Fix descriptions borders against the light-theme card by
painting the row divider on each cell's bottom edge — AD-Vue's <tr>
border doesn't render reliably under border-collapse:collapse.

Backend: serveSubPage reads dist/subpage.html, injects
window.__X_UI_BASE_PATH__ + window.__SUB_PAGE_DATA__ before </head>,
and rewrites Vite's absolute /assets/ URLs when the panel runs under
a URL prefix. Drop the legacy template-FuncMap wiring and switch the
sub server's static mount from web/assets to web/dist/assets.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): inbound modal QR + tabs + restored TLS fallbacks

Per-client QR action: the qr icon on the expand-row table opened the
big info modal instead of the QR modal. Route it to QrCodeModal and
extend that modal with a `client` prop so genAllLinks() produces the
per-client share URLs (and per-peer remarks for WireGuard).

Inbound's Data redesign: split the dense single-page view into three
tabs — Inbound, Client, Subscription. Drop every QR rendering from
this modal (QrCodeModal is the QR home now). Each row in the Inbound
tab is one label/value pair instead of the legacy 2-column grid, and
long values like the VLESS encryption blob render as a wrapping code
block with a copy button so they can't blow out the dialog. The
Subscription tab renders sub URL + JSON URL as clickable anchors that
open in a new tab.

Restored TLS fallbacks UI: the model already exposed
VLESSSettings.Fallback / TrojanSettings.Fallback with addFallback /
delFallback / fallbackToJson, but the form modal never surfaced them
during the Vue 3 migration. Re-add the legacy form (SNI, ALPN, Path,
Destination, PROXY) on the protocol tab, gated on TCP transport plus
(for VLESS) encryption=none — same conditions as main.

Column widths: Protocol 70→130 and All-time Traffic 60→95 in the
inbound list; All-time Traffic 90→130 in the client expand-row, so
the header text fits and tags don't get squeezed.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): navy dark theme + rounded inbound/client corners

Dark theme picks up a refined navy palette (page #0a1426, cards
#142340, sider #0d1d33) so the sidebar blends with the rest of the
surface; ultra-dark stays neutral black. Resolves the previous mismatch
where AD-Vue 4 hardcoded #001529 / #002140 for the sider, trigger and
dark Menu items via Layout.colorBgHeader / colorBgTrigger and Menu's
colorItemBg — overrides go through the component-token map now.

Round the inbound table's outer corners (header start/end + last row
end) and wrap the client expand-row grid in a 1px / 8px-radius border
so the list reads as a contained block instead of a flush rectangle.

Linter-driven whitespace cleanup across inbounds/*.vue rolled into the
same commit since it can't be split out cleanly.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): xray tab fixes — modal close, tag validation, full XHTTP, reset to default

Modal close: BalancersTab / OutboundsTab / RoutingTab confirmDelete used
arrow expressions that returned splice's removed-items array. AD-Vue 4
treats truthy non-thenables from onOk as "still pending" and never closes
the dialog (see ActionButton.js:103-106), so the confirm modal stayed
open. Wrap the body so onOk returns undefined and AD-Vue auto-closes.

Tag validation: outbound + balancer modals only flipped between
warning/success on duplicate, leaving the empty case as a green ✓.
Split into a 3-state computed — error (empty) / warning (duplicate) /
success — and wire a help message so the input clearly explains why
the OK button is disabled.

Reset to default: re-add the legacy "Reset to Default" panel at the
bottom of BasicsTab. Calls /panel/setting/getDefaultJsonConfig and
overwrites templateSettings; the existing watch re-stringifies so the
JSON tab + dirty-poll see the new state.

Restored Basics option lists from main: IPs (4→10, +Vietnam/Spain/
Indonesia/Ukraine/Türkiye/Brazil), DomainsOptions (4→10, +regex
entries), BlockDomainsOptions (5→17, +Malware/Phishing/Adult/regex),
ServicesOptions (Reddit/Speedtest in, off-template Microsoft out).

Outbound form parity with main:
  • Reverse Sniffing UI for VLESS — toggle + destOverride checkboxes
    (HTTP/TLS/QUIC/FAKEDNS) + Metadata/Route Only + IPs/Domains
    excluded multi-selects, gated on reverseTag being set.
  • Full XHTTP transport — request headers list, Max Upload Size /
    Min Upload Interval (packet-up), Padding Obfs Mode + sub-fields,
    Uplink HTTP Method, Session/Sequence/UplinkData placement +
    keys, No gRPC Header (stream-up/stream-one), expanded XMUX with
    Max Concurrency/Connections/Reuse/Request/Reusable/Keep-alive.

Strip a-divider from the outbound form per request — replaced with
plain section/item heading divs so the labels and per-row delete
icons stay but the horizontal rule is gone.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): xray Advanced tab parity + finalmask gating

Advanced tab was a single textarea bound to the full xraySetting blob.
Restore the legacy 4-way view: a radio group toggles between All /
Inbounds / Outbounds / Routing Rules, and the textarea reads/writes
the matching slice through templateSettings. Added the legacy header
("Advanced Xray Configuration Template" + description) so the page
introduces itself like main.

Outbound finalmask leaked into protocols that don't have a stream
(Freedom / Blackhole / DNS / Socks / HTTP / Wireguard) because the
v-if only checked outbound.stream. Gate the whole FinalMaskForm on
outbound.canEnableStream() to match main.

Drop the leading divider inside FinalMaskForm — its parent already
provides separation, so the rule above "TCP Masks" was redundant.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): inbound Advanced tab live mirror + QR exact-fit sizing

Advanced tab in the inbound modal showed stale state. The watch only
refreshed advancedJson.stream, so toggling the Sniffing switch in the
Sniffing tab left the Advanced JSON showing the prior value. And
encryption — stored on inbound.settings.encryption, not on stream —
never appeared at all because Advanced only exposed stream + sniffing.

Split the watch into three (stream / sniffing / settings) and add a
settings textarea so encryption / clients / fallbacks live alongside
the existing two views. The submit() path now reads settings from
the JSON tab too (falling back to inbound.settings.toString()) so
power-user edits in Advanced override the structured form on save.

QR canvas: when a longer share-URL bumps the QR matrix size, QRious
falls back to floor(canvasSize / matrixWidth) and centers the pattern,
leaving a white margin (e.g. matrix=41, size=180 → 8px gap). Pre-pick
the QR version from the URL byte length and set canvas size to a
multiple of matrixWidth × pixelSize so the pattern always fills it
edge-to-edge — no white margin even after toggling encryption on.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): inbound stream tidy-up + QR sizing + dev proxy

Stream tab clean-up: drop the seven a-divider rules in the inbound
form's Stream tab — replace the labelled ones (Request / Response /
Security) with a section-heading div that matches the outbound modal,
delete the empty rules above TLS sub-blocks / External Proxy /
Sockopt. Empty header-list form-items also leaked margin space below
each "Add header" button across TCP / WS / HTTPUpgrade / XHTTP — gate
each on headers.length > 0 so they vanish until the user adds one.

QR panel: drop the link text under the canvas (the user already has
a copy button on the header). Pin the canvas display size to a fixed
240px square via :style + image-rendering: pixelated/crisp-edges so
a dense WireGuard config QR and its sparser link share the same
on-screen footprint without blurring.

Dev proxy: Node's AggregateError wraps connection failures whenever
DNS returns more than one address (::1 + 127.0.0.1) and the code
lands on the inner errors, not the outer. The existing handler only
checked err.code so the ECONNREFUSED stack still spammed the log
when the Go backend was down. Walk err.errors too, print one
friendly line ("backend not reachable — start the Go server"), then
stay quiet for the rest of the session.

Vendor splitting + chunk-size warning: split node_modules into
stable vendor-* chunks so each page only ships the deps it uses and
the browser caches them across versions. ant-design-vue stays as a
single chunk because its components share internals; raise the
chunk-size warning to 1500kB so the build stays quiet (its 1.4MB
minified gzips to ~410kB on the wire).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): info-modal cleanup + 2FA QR + outbound link import

- 2FA QR: matrix-snap canvas + opaque background to drop white margin
- Inbound info modal: stack Mixed/HTTP/Tunnel as info-rows, hide tab
  strip when only the Inbound tab applies
- Add inline VLESS Reverse tag input on first-client form
- Hide Protocol tab for TUN (no form yet)
- Outbound link converter: route through Outbound.fromLink so
  vless/trojan/ss/hysteria(2) imports work alongside vmess; fix stray
  implicit global in fromLink

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): jalali calendar + drop legacy moment-jalali

- Wire Calendar Type setting to a real Jalali datepicker via
  vue3-persian-datetime-picker, gated by useDatepicker composable
- DateTimePicker wrapper swaps between AD-Vue and Persian picker; keeps
  dayjs v-model contract so existing forms/setters work unchanged
- Theme picker popup explicitly per body.dark / data-theme=ultra-dark
  (AD-Vue 4 doesn't expose CSS vars, so var() fallbacks defaulted to
  white); fix invisible disabled days, SVG arrow fills, popup clipping
  via append-to="body"
- Replace stray moment() calls in dbinbound/inbound models with dayjs;
  the legacy global was undefined under ESM and broke the inbounds list
  whenever any inbound had expiryTime > 0
- Remove legacy moment-jalali / persian-datepicker / aPersianDatepicker
  assets — replaced by the Vue 3 picker

Note: dark/ultra background of the date popup still renders white in
some cases — pending follow-up.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): jalali popup theming + full-month layout

- Re-prefix popup selectors with .vpd-wrapper (popup root that travels
  with appendTo='body'), not .vpd-main (which stays at the input);
  paints the popup's dark/ultra background again
- Drop the 1px border on .vpd-content — with box-sizing: border-box
  it ate 2px from the day-row width, wrapping the 7th cell of every
  row and hiding days 18-31 of months that needed a 5th week

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat: render dates in Jalali when Calendar Type is jalalian

- IntlUtil.formatDate accepts an optional calendar arg; appends the
  BCP-47 -u-ca-persian extension so Intl renders Jalali across all UI
  languages, not just fa-IR
- Plumb the panel's datepicker setting into the SubPage via the Go
  injection (window.__SUB_PAGE_DATA__.datepicker)
- Panel pages (inbound list/info, client row, xray log) read the same
  setting through the useDatepicker composable so the whole panel
  stays consistent

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(frontend): ultra-dark page tint + mobile-friendly inbound view

- Drop --bg-page from #21242a (lighter than the cards) to #050505 in
  ultra-dark across index/sub/settings/inbounds/xray, so cards
  consistently elevate over the page
- Hide the inline sider's children + collapse-trigger and zero its
  width below 768px; the floating drawer-handle remains the menu
  trigger
- Inbounds page mobile pass: tighten content-area + card padding;
  flex-wrap the filter bar instead of stacking; shrink table cell
  padding so all 4 mobile columns fit; bump expand / action / info
  icon hit targets
- Per-client expand row on mobile: soft-tinted rounded cards instead
  of hairline borders, larger action / info touch targets, more
  legible email typography, bigger status badge dot

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* chore: remove legacy template + asset trees and dead Go template engine

- Delete web/html/ entirely (page templates, form/, modals/, component/,
  common/, settings/) — every route is served from web/dist/ now via
  serveDistPage; nothing in the binary referenced these
- Delete web/assets/ entirely (jQuery-era ant-design-vue, axios, moment,
  codemirror, qrcode/qs/uri/vue/otpauth, custom CSS, Vazirmatn font);
  Vite bundles all of this into web/dist/assets
- Drop the Gin HTML template wiring: remove //go:embed assets +
  //go:embed html/*, the assetsFS/htmlFS vars, the wrapAssetsFS adapter,
  EmbeddedHTML / EmbeddedAssets exports, getHtmlFiles / getHtmlTemplate,
  the i18nWebFunc/funcMap and SetFuncMap call, and the dev/prod
  template-engine branch — only StaticFS for /assets/ is needed now
- Remove dead html()/getContext() helpers and unused imports from
  web/controller/util.go (no c.HTML(...) callers remain)

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(frontend): inbound expand chevron position + cpu history layout

- Push the inbound table's expand chevron away from the left edge with
  margin-inline + cell padding so it isn't flush against the corner
- Move "Timeframe: …" caption above the chart (was below); restore
  the line that the previous edit removed
- Fix x-axis time labels being clipped at the bottom of the cpu chart
  — the offset (paddingTop+drawHeight+22 = 222) exceeded the SVG
  viewBox height (220); dropped to +14 so labels sit at y=214 with
  room for descenders
- Move the SVG axis text colors out of <style scoped> into a global
  block — Vue's scoped CSS doesn't always hash-attribute SVG <text>
  descendants, so the dark-mode overrides via :global() weren't
  matching; bumped opacity 0.55 → 0.85 for legibility on navy/black

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(login): language picker in settings popover + fluid card sizing

- Add language select alongside the theme switch (mirrors SubPage)
- Bind headline to pages.login.hello / pages.login.title so the
  "Hello / Welcome" cycle re-translates with the active locale
- Replace AD-Vue 5-breakpoint grid with clamp() sizing so the card
  scales smoothly instead of jumping ~33% at each breakpoint
- Pin horizontal padding so input width stays stable on large viewports

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(frontend): organize entry HTML + bootstrap JS into folders

- Move entry HTML files: frontend/*.html -> frontend/html/*.html
- Move per-page bootstrap modules: src/{index,login,settings,inbounds,xray,subpage}.js -> src/entries/
- Update vite.config rollup inputs and dev-mode MIGRATED_ROUTES to /html/<page>.html
- Build output now lands at web/dist/html/<page>.html
- serveDistPage and subController updated to read from dist/html/

Cleans up the flat frontend/ root which previously interleaved 6 HTML
files with package.json, README, src/, etc. The src/ root similarly
gets rid of 6 entry .js files mixed in alongside api/, components/,
models/, etc.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* chore: remove obsolete vue3 phase1 inventory doc

The migration is well past phase 1 — the inventory doc has rotted
and the live state lives in the codebase plus the plan files.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(frontend): merge utils/legacy.js into utils/index.js

The barrel was a placeholder for an eventual split that hasn't
happened. Collapsing the two files removes one layer of indirection
and the misleading "legacy" name (the contents are still actively
used by the migrated SPA).

- Move all 930 lines from utils/legacy.js into utils/index.js
- Delete utils/legacy.js
- Update direct import in models/outbound.js to '@/utils'
- Drop a stale legacy.js reference in InboundFormModal comment

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* revert(frontend): keep entry HTML files at frontend/ root

The earlier move to frontend/html/ made dev-mode URLs ugly
(http://localhost:5173/html/index.html instead of plain /). The folder
didn't add real value — it just hid 6 files behind a non-conventional
layout. Reverting that piece while keeping src/entries/ (which is a
genuine separation between page bootstrap and the rest of src/).

- HTML files back at frontend/<page>.html
- Vite rollupOptions.input + MIGRATED_ROUTES restored to flat paths
- Build output is web/dist/<page>.html again
- web/controller/dist.go and sub/subController.go read from dist/<name>

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* build(frontend): bump eslint to 10 + add flat config + clean lint warnings

- Upgrade eslint 9.39 -> 10.3 and eslint-plugin-vue 9.33 -> 10.9
- Add eslint.config.js (flat config required by ESLint 10) with
  vue3-recommended rules, sensible defaults, and exemptions for the
  project's existing formatting style
- Drop --ext from the lint script (removed in ESLint 10)
- vue/no-mutating-props is left off because the form-modal pattern
  ports straight from Vue 2 (parent passes a reactive object, child
  mutates it); a real fix is an architectural rewire, separate task

Lint warning cleanup:
- utils/index.js: var -> let/const in the X25519 routines, replace
  obj.hasOwnProperty(...) with Object.prototype.hasOwnProperty.call(...)
- Remove unused imports (reactive, ref, Inbound) in ClientFormModal,
  InboundInfoModal, QrCodeModal, DnsServerModal, OutboundFormModal,
  SubPage; remove unused locals (isClientOnline, ONLINE_GRACE_MS,
  fetchAll, isSocks, isHTTP, _antdAlgorithm)
- XrayStatusCard: declare 'open-logs' on defineEmits (was emitted but
  not declared)
- RuleFormModal: rename v-for var t -> tag (shadowed useI18n's t)
- Drop stale eslint-disable directives (no-new, no-unused-vars)
- OutboundsTab/InboundList: drop redundant initial null assigns
- InboundInfoModal/OutboundFormModal: explicit eslint-disable for the
  intentional local-ref-shadows-prop pattern in modal drafts

`npm run lint` now passes with 0 errors and 0 warnings.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(inbounds): one client identity across multiple inbounds via subId

Lets the operator add the same email under the same subId to several
inbounds. Xray reports traffic per email, so a single client_traffics
row acts as the shared accumulator — no aggregation overhead, quota and
expiry stay consistent.

- Email validation allows duplicates only when subId matches
- AddClientStat upserts via OnConflict DoNothing (idempotent on rerun)
- Stat/IP rows survive client deletion when a sibling inbound still
  references the email
- enrichClientStats tops up GORM-preloaded stats with rows whose
  inbound_id points at a sibling, so every panel view sees usage
- disableInvalidClients cascades enable=false and syncs the row's
  total/expiry into every sibling JSON when the shared identity expires
- DelDepletedClients removes the depleted client from all referencing
  inbounds, batched
- Subscription services dedupe traffic by email so shared quota is
  counted once

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* docs(frontend): rewrite README for multi-page Vue 3 layout

Reflects the current state — embedded build, per-route HTML entries,
ESLint 10 flat config, src/ layout, and the steps to add a new page.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* build(frontend): drop deprecated rimraf/glob/inflight transitive deps

vue3-persian-datetime-picker pinned moment-jalaali to ^0.9.4, which
pulled rimraf@3 → glob@7 → inflight@1. inflight in particular leaks
memory and is unmaintained. Override moment-jalaali to ^0.10.4 (same
runtime API, dropped the legacy build deps) so npm install no longer
warns and the dep tree is 12 packages lighter.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(nodes): multi-node panel orchestration (CRUD, deployment, traffic sync, sub per-node)

- Node model + service + controller (/panel/api/nodes/*) with bearer-token apiToken auth
- Heartbeat job @every 10s; status/latency/xrayVersion surfaced in Nodes UI
- Runtime abstraction (Local + Remote) so inbound/client mutations target the
  inbound's owning node instead of always hitting the local xray
- Inbounds gain optional NodeID; tag-based correlation with remote panel (no
  RemoteInboundID column needed)
- NodeTrafficSyncJob @every 10s pulls absolute counters + online/lastOnline
  from each enabled+online node and writes them into central DB; 30s reset
  grace window prevents post-reset overwrite
- Reset propagation to nodes (best-effort) on client/inbound/all reset paths
- Subscription server uses node.Address for inbounds with NodeID, falling back
  to existing host resolution for local inbounds
- Frontend: Nodes page, "Deploy to" select in inbound form, Node column on
  inbound list, hostOverride threaded through genAllLinks/QR/Info modals

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(stats): system history modal + per-node CPU/Mem trends across all locales

Backend
- web/service/metric_history.go: generic in-memory ring buffer with two
  singletons — system-wide (cpu/mem/netUp/netDown/online/load1/5/15)
  and per-node (cpu/mem) keyed by node id
- ServerService.AppendStatusSample writes all 8 metrics every 2s on the
  same tick; AppendCpuSample/AggregateCpuHistory kept for back-compat
- NodeService.UpdateHeartbeat appends cpu/mem only on online ticks so
  offline gaps render as missing data, not phantom dips
- New routes: GET /panel/api/server/history/:metric/:bucket and
  GET /panel/api/nodes/history/:id/:metric/:bucket, both whitelisted

Frontend
- Sparkline component generalized: arbitrary value range (auto-scale
  when valueMax=null), pluggable yFormatter/tooltipFormatter for B/s,
  client counts, load averages
- SystemHistoryModal replaces CpuHistoryModal with tabs for every
  metric; opened from a tag on the 3X-UI card next to Documentation
- NodeHistoryPanel: expandable row on the Nodes table showing per-node
  CPU and Mem trends, refreshed every 15s

Localization
- Backfill systemHistoryTitle / trendLast2Min / pages.inbounds.{node,
  deployTo, localPanel} and the entire pages.nodes block (51 keys
  including statusValues + toasts) into all 11 non-en/fa locales:
  ar-EG, es-ES, id-ID, ja-JP, pt-BR, ru-RU, tr-TR, uk-UA, vi-VN,
  zh-CN, zh-TW

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* fix(embed): include underscore-prefixed Vite chunks in dist FS

go:embed silently excludes files whose names start with `_` or `.`,
so the `_plugin-vue_export-helper-<hash>.js` chunk that Vite/rolldown
emits for @vitejs/plugin-vue was missing from the production binary.
First import at runtime hit a 404 and the SPA failed to mount — blank
page on every page load, no error in the server logs because the
asset 404 was just a static-handler miss.

Switched the directive to `//go:embed all:dist` which keeps the same
root layout but disables the underscore/dot exclusion rule. Dev mode
was unaffected (it serves dist/assets/ from disk, not the embedded FS).

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* ci: build frontend bundle before Go compile in release.yml + Dockerfile

Phase 8 cut all panel HTML routes over to web/dist/ and embedded the
Vite bundle into the Go binary via //go:embed all:dist. web/dist/ is
.gitignored, so on a fresh CI checkout it doesn't exist — every Go
build since Phase 8 has been failing with "pattern dist: no matching
files found" or producing a binary that 404s on first asset request.

release.yml: add a setup-node@v4 + npm ci + npm run build trio before
the existing go build step in both the Linux matrix job (7 arches)
and the Windows job. npm cache is keyed on frontend/package-lock.json.

Dockerfile: add a node:22-alpine frontend stage that runs npm ci +
npm run build and emits to /src/web/dist (via vite.config.js's outDir).
The golang builder stage then COPY --from=frontend /src/web/dist into
./web/dist before the go build, so embed.FS sees the bundle.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(ws): live updates on inbounds/xray/nodes pages, drop polling + manual refresh

Replaces the legacy polling + manual-refresh model with WebSocket pushes
across the three live-data pages. The hub already broadcast traffic /
client_stats / outbounds; this wires the frontend to consume them and
adds a new `nodes` channel for the heartbeat job's snapshot.

Frontend
- new useWebSocket composable: page-scoped singleton WebSocketClient,
  lifecycle-managed on/off, leaves disconnect to page-unload
- inbounds: useInbounds gains applyTrafficEvent / applyClientStatsEvent
  / applyInvalidate that merge counters and online/lastOnline in place;
  InboundsPage subscribes; InboundList drops the auto-refresh popover,
  the refresh button, and the now-unused refreshing prop
- xray outbounds: useXraySetting gains applyOutboundsEvent; XrayPage
  subscribes; OutboundsTab drops the refresh button + emit
- nodes: useNodes gains applyNodesEvent and stops the 5s
  setInterval/visibilitychange polling; NodesPage subscribes;
  NodeList drops the refresh button and ReloadOutlined import

Backend
- web/websocket: new MessageTypeNodes + BroadcastNodes notifier
- node_heartbeat_job: after wg.Wait(), reload the table once and
  BroadcastNodes(updated). Gated on websocket.HasClients() so a panel
  with no open browser doesn't spend the DB read

Bug fixes spotted in this pass
- websocket.js #buildUrl defaulted basePath to '' when the global was
  missing (dev mode), producing `ws://host:portws` and a SyntaxError
  on the WebSocket constructor. Fall back to '/' and ensure leading
  slash.
- vite.config.js: forward /ws to ws://localhost:2053 with ws:true so
  dev (5173) reaches the Go backend's WebSocket
- NodeFormModal: a-input-password's visibilityToggle is Boolean in
  AntD Vue 4; the v3-era object form (`{ visible, 'onUpdate:visible' }`)
  triggered a Vue prop-type warning. Drop the override (default true
  shows the eye icon and toggles internally) and remove the orphaned
  tokenVisible ref

Translations
- pages.inbounds.autoRefresh / autoRefreshInterval: removed from all
  13 locales (UI gone)
- pages.nodes.refresh: removed from all 13 locales (UI gone)

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* feat(inbounds): hide Node column when no nodes are defined

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Sanaei 12 годин тому
батько
коміт
bc00d37ad8
100 змінених файлів з 22678 додано та 199 видалено
  1. 6 3
      .github/copilot-instructions.md
  2. 34 0
      .github/workflows/release.yml
  3. 18 0
      Dockerfile
  4. 1 0
      database/db.go
  5. 37 0
      database/model/model.go
  6. 3 0
      frontend/.gitignore
  7. 75 0
      frontend/README.md
  8. 61 0
      frontend/eslint.config.js
  9. 13 0
      frontend/inbounds.html
  10. 13 0
      frontend/index.html
  11. 14 0
      frontend/login.html
  12. 13 0
      frontend/nodes.html
  13. 2785 0
      frontend/package-lock.json
  14. 38 0
      frontend/package.json
  15. 13 0
      frontend/settings.html
  16. 117 0
      frontend/src/api/axios-init.js
  17. 9 5
      frontend/src/api/websocket.js
  18. 186 0
      frontend/src/components/AppSidebar.vue
  19. 27 0
      frontend/src/components/CustomStatistic.vue
  20. 384 0
      frontend/src/components/DateTimePicker.vue
  21. 542 0
      frontend/src/components/FinalMaskForm.vue
  22. 25 0
      frontend/src/components/InfinityIcon.vue
  23. 70 0
      frontend/src/components/PromptModal.vue
  24. 31 0
      frontend/src/components/SettingListItem.vue
  25. 317 0
      frontend/src/components/Sparkline.vue
  26. 300 0
      frontend/src/components/TableSortable.vue
  27. 67 0
      frontend/src/components/TextModal.vue
  28. 46 0
      frontend/src/components/ThemeSwitch.vue
  29. 25 0
      frontend/src/components/ThemeSwitchLogin.vue
  30. 45 0
      frontend/src/composables/useDatepicker.js
  31. 26 0
      frontend/src/composables/useMediaQuery.js
  32. 42 0
      frontend/src/composables/useNodeList.js
  33. 43 0
      frontend/src/composables/useStatus.js
  34. 128 0
      frontend/src/composables/useTheme.js
  35. 48 0
      frontend/src/composables/useWebSocket.js
  36. 17 0
      frontend/src/entries/inbounds.js
  37. 19 0
      frontend/src/entries/index.js
  38. 21 0
      frontend/src/entries/login.js
  39. 17 0
      frontend/src/entries/nodes.js
  40. 19 0
      frontend/src/entries/settings.js
  41. 18 0
      frontend/src/entries/subpage.js
  42. 17 0
      frontend/src/entries/xray.js
  43. 93 0
      frontend/src/i18n/index.js
  44. 11 4
      frontend/src/models/dbinbound.js
  45. 58 43
      frontend/src/models/inbound.js
  46. 39 37
      frontend/src/models/outbound.js
  47. 24 24
      frontend/src/models/reality-targets.js
  48. 8 1
      frontend/src/models/setting.js
  49. 78 0
      frontend/src/models/status.js
  50. 273 0
      frontend/src/pages/inbounds/ClientBulkModal.vue
  51. 394 0
      frontend/src/pages/inbounds/ClientFormModal.vue
  52. 610 0
      frontend/src/pages/inbounds/ClientRowTable.vue
  53. 1790 0
      frontend/src/pages/inbounds/InboundFormModal.vue
  54. 1012 0
      frontend/src/pages/inbounds/InboundInfoModal.vue
  55. 621 0
      frontend/src/pages/inbounds/InboundList.vue
  56. 692 0
      frontend/src/pages/inbounds/InboundsPage.vue
  57. 67 0
      frontend/src/pages/inbounds/QrCodeModal.vue
  58. 158 0
      frontend/src/pages/inbounds/QrPanel.vue
  59. 323 0
      frontend/src/pages/inbounds/useInbounds.js
  60. 101 0
      frontend/src/pages/index/BackupModal.vue
  61. 106 0
      frontend/src/pages/index/CustomGeoFormModal.vue
  62. 311 0
      frontend/src/pages/index/CustomGeoSection.vue
  63. 394 0
      frontend/src/pages/index/IndexPage.vue
  64. 165 0
      frontend/src/pages/index/LogModal.vue
  65. 112 0
      frontend/src/pages/index/PanelUpdateModal.vue
  66. 96 0
      frontend/src/pages/index/StatusCard.vue
  67. 160 0
      frontend/src/pages/index/SystemHistoryModal.vue
  68. 147 0
      frontend/src/pages/index/VersionModal.vue
  69. 182 0
      frontend/src/pages/index/XrayLogModal.vue
  70. 144 0
      frontend/src/pages/index/XrayStatusCard.vue
  71. 350 0
      frontend/src/pages/login/LoginPage.vue
  72. 223 0
      frontend/src/pages/nodes/NodeFormModal.vue
  73. 134 0
      frontend/src/pages/nodes/NodeHistoryPanel.vue
  74. 207 0
      frontend/src/pages/nodes/NodeList.vue
  75. 243 0
      frontend/src/pages/nodes/NodesPage.vue
  76. 120 0
      frontend/src/pages/nodes/useNodes.js
  77. 425 0
      frontend/src/pages/settings/GeneralTab.vue
  78. 245 0
      frontend/src/pages/settings/SecurityTab.vue
  79. 309 0
      frontend/src/pages/settings/SettingsPage.vue
  80. 433 0
      frontend/src/pages/settings/SubscriptionFormatsTab.vue
  81. 196 0
      frontend/src/pages/settings/SubscriptionGeneralTab.vue
  82. 106 0
      frontend/src/pages/settings/TelegramTab.vue
  83. 181 0
      frontend/src/pages/settings/TwoFactorModal.vue
  84. 80 0
      frontend/src/pages/settings/useAllSetting.js
  85. 465 0
      frontend/src/pages/sub/SubPage.vue
  86. 133 0
      frontend/src/pages/xray/BalancerFormModal.vue
  87. 210 0
      frontend/src/pages/xray/BalancersTab.vue
  88. 500 0
      frontend/src/pages/xray/BasicsTab.vue
  89. 168 0
      frontend/src/pages/xray/DnsServerModal.vue
  90. 373 0
      frontend/src/pages/xray/DnsTab.vue
  91. 379 0
      frontend/src/pages/xray/NordModal.vue
  92. 1007 0
      frontend/src/pages/xray/OutboundFormModal.vue
  93. 499 0
      frontend/src/pages/xray/OutboundsTab.vue
  94. 405 0
      frontend/src/pages/xray/RoutingTab.vue
  95. 263 0
      frontend/src/pages/xray/RuleFormModal.vue
  96. 347 0
      frontend/src/pages/xray/WarpModal.vue
  97. 431 0
      frontend/src/pages/xray/XrayPage.vue
  98. 246 0
      frontend/src/pages/xray/useXraySetting.js
  99. 87 82
      frontend/src/utils/index.js
  100. 14 0
      frontend/subpage.html

+ 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
 

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

@@ -83,6 +83,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@v4
+        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
@@ -209,6 +226,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@v4
+        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>

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

@@ -0,0 +1,317 @@
+<script setup>
+import { computed, 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);
+
+const viewBoxAttr = computed(() => `0 0 ${props.vbWidth} ${props.height}`);
+const drawWidth = computed(() => Math.max(1, props.vbWidth - 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) * props.vbWidth;
+  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
+    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>

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

@@ -0,0 +1,394 @@
+<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, isMobile ? 0 : 12]">
+              <a-col :span="24">
+                <StatusCard :status="status" :is-mobile="isMobile" />
+              </a-col>
+
+              <a-col :sm="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 :sm="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 :sm="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>
+                  <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>
+                  <a-tag color="blue" class="history-tag" @click="openSystemHistory">
+                    <AreaChartOutlined />
+                    {{ t('pages.index.systemHistoryTitle') }}
+                  </a-tag>
+                </a-card>
+              </a-col>
+
+              <a-col :sm="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 :sm="24" :lg="12">
+                <a-card :title="t('pages.index.systemLoad')" hoverable>
+                  <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 :sm="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 :sm="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 :sm="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 :sm="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 :sm="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;
+}
+
+.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;
+}
+
+.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 :sm="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 :sm="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>

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

@@ -0,0 +1,160 @@
+<script setup>
+import { computed, ref, watch } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { HttpUtil, SizeFormatter } from '@/utils';
+import Sparkline from '@/components/Sparkline.vue';
+
+const { t } = useI18n();
+
+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="900px" @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
         )
 

+ 14 - 0
frontend/subpage.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>Subscription</title>
+  </head>
+  <body>
+    <div id="message"></div>
+    <div id="app"></div>
+    <script type="module" src="/src/entries/subpage.js"></script>
+  </body>
+</html>

Деякі файли не було показано, через те що забагато файлів було змінено