1
0

5 کامیت‌ها 3452267302 ... a2c2c5f41d

نویسنده SHA1 پیام تاریخ
  Sanaei a2c2c5f41d Merge branch 'main' into bash 1 روز پیش
  Sanaei edf0f36940 Frontend rewrite: React + TypeScript with AntD v6 (#4498) 1 روز پیش
  MHSanaei 237b7c898d Bump frontend deps: vue and vite 2 روز پیش
  MHSanaei 7368359924 fix(xray): resolve relative log paths under panel log folder 2 روز پیش
  MHSanaei f2f5d584b3 fix(frontend): stack form fields on mobile in client/inbound/node modals 2 روز پیش
100فایلهای تغییر یافته به همراه6681 افزوده شده و 4247 حذف شده
  1. 0 2
      .github/workflows/ci.yml
  2. 0 2
      .github/workflows/codeql.yml
  3. 2 2
      .github/workflows/release.yml
  4. 3 0
      .gitignore
  5. 67 63
      CONTRIBUTING.md
  6. 1 1
      DockerInit.sh
  7. 0 1
      database/db.go
  8. 18 18
      database/model/model.go
  9. 22 14
      frontend/README.md
  10. 1 1
      frontend/api-docs.html
  11. 1 1
      frontend/clients.html
  12. 44 31
      frontend/eslint.config.js
  13. 1 1
      frontend/inbounds.html
  14. 1 1
      frontend/index.html
  15. 1 1
      frontend/login.html
  16. 1 1
      frontend/nodes.html
  17. 1093 158
      frontend/package-lock.json
  18. 22 19
      frontend/package.json
  19. 1 1
      frontend/settings.html
  20. 287 0
      frontend/src/components/AppSidebar.css
  21. 290 0
      frontend/src/components/AppSidebar.tsx
  22. 0 432
      frontend/src/components/AppSidebar.vue
  23. 52 0
      frontend/src/components/CustomStatistic.css
  24. 14 0
      frontend/src/components/CustomStatistic.tsx
  25. 0 31
      frontend/src/components/CustomStatistic.vue
  26. 35 0
      frontend/src/components/DateTimePicker.css
  27. 98 0
      frontend/src/components/DateTimePicker.tsx
  28. 0 366
      frontend/src/components/DateTimePicker.vue
  29. 738 0
      frontend/src/components/FinalMaskForm.tsx
  30. 0 510
      frontend/src/components/FinalMaskForm.vue
  31. 19 0
      frontend/src/components/InfinityIcon.tsx
  32. 0 18
      frontend/src/components/InfinityIcon.vue
  33. 40 0
      frontend/src/components/InputAddon.css
  34. 21 0
      frontend/src/components/InputAddon.tsx
  35. 26 0
      frontend/src/components/JsonEditor.css
  36. 179 0
      frontend/src/components/JsonEditor.tsx
  37. 0 185
      frontend/src/components/JsonEditor.vue
  38. 82 0
      frontend/src/components/PromptModal.tsx
  39. 0 52
      frontend/src/components/PromptModal.vue
  40. 43 0
      frontend/src/components/SettingListItem.css
  41. 36 0
      frontend/src/components/SettingListItem.tsx
  42. 0 35
      frontend/src/components/SettingListItem.vue
  43. 44 0
      frontend/src/components/Sparkline.css
  44. 368 0
      frontend/src/components/Sparkline.tsx
  45. 0 297
      frontend/src/components/Sparkline.vue
  46. 0 311
      frontend/src/components/TableSortable.vue
  47. 59 0
      frontend/src/components/TextModal.tsx
  48. 0 66
      frontend/src/components/TextModal.vue
  49. 0 45
      frontend/src/composables/useDatepicker.js
  50. 0 26
      frontend/src/composables/useMediaQuery.js
  51. 0 44
      frontend/src/composables/useNodeList.js
  52. 0 43
      frontend/src/composables/useStatus.js
  53. 0 128
      frontend/src/composables/useTheme.js
  54. 0 48
      frontend/src/composables/useWebSocket.js
  55. 0 21
      frontend/src/entries/api-docs.js
  56. 28 0
      frontend/src/entries/api-docs.tsx
  57. 0 21
      frontend/src/entries/clients.js
  58. 28 0
      frontend/src/entries/clients.tsx
  59. 0 21
      frontend/src/entries/inbounds.js
  60. 28 0
      frontend/src/entries/inbounds.tsx
  61. 0 23
      frontend/src/entries/index.js
  62. 28 0
      frontend/src/entries/index.tsx
  63. 0 23
      frontend/src/entries/login.js
  64. 28 0
      frontend/src/entries/login.tsx
  65. 0 21
      frontend/src/entries/nodes.js
  66. 28 0
      frontend/src/entries/nodes.tsx
  67. 0 23
      frontend/src/entries/settings.js
  68. 28 0
      frontend/src/entries/settings.tsx
  69. 0 20
      frontend/src/entries/subpage.js
  70. 23 0
      frontend/src/entries/subpage.tsx
  71. 0 21
      frontend/src/entries/xray.js
  72. 28 0
      frontend/src/entries/xray.tsx
  73. 65 0
      frontend/src/env.d.ts
  74. 69 0
      frontend/src/hooks/useAllSetting.ts
  75. 282 0
      frontend/src/hooks/useClients.ts
  76. 57 0
      frontend/src/hooks/useDatepicker.ts
  77. 15 0
      frontend/src/hooks/useMediaQuery.ts
  78. 177 0
      frontend/src/hooks/useNodes.ts
  79. 35 0
      frontend/src/hooks/useStatus.ts
  80. 136 0
      frontend/src/hooks/useTheme.tsx
  81. 32 0
      frontend/src/hooks/useWebSocket.ts
  82. 370 0
      frontend/src/hooks/useXraySetting.ts
  83. 0 54
      frontend/src/i18n/index.js
  84. 43 0
      frontend/src/i18n/react.ts
  85. 0 108
      frontend/src/models/setting.js
  86. 100 0
      frontend/src/models/setting.ts
  87. 0 71
      frontend/src/models/status.js
  88. 120 0
      frontend/src/models/status.ts
  89. 292 0
      frontend/src/pages/api-docs/ApiDocsPage.css
  90. 247 0
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  91. 0 561
      frontend/src/pages/api-docs/ApiDocsPage.vue
  92. 0 67
      frontend/src/pages/api-docs/CodeBlock.css
  93. 69 0
      frontend/src/pages/api-docs/CodeBlock.tsx
  94. 93 0
      frontend/src/pages/api-docs/EndpointRow.css
  95. 84 0
      frontend/src/pages/api-docs/EndpointRow.tsx
  96. 0 172
      frontend/src/pages/api-docs/EndpointRow.vue
  97. 2 65
      frontend/src/pages/api-docs/EndpointSection.css
  98. 90 0
      frontend/src/pages/api-docs/EndpointSection.tsx
  99. 5 0
      frontend/src/pages/clients/ClientBulkAddModal.css
  100. 341 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx

+ 0 - 2
.github/workflows/ci.yml

@@ -10,7 +10,6 @@ on:
       - "**.mjs"
       - "**.cjs"
       - "**.ts"
-      - "**.vue"
       - "**.html"
       - "**.css"
       - "frontend/package.json"
@@ -27,7 +26,6 @@ on:
       - "**.mjs"
       - "**.cjs"
       - "**.ts"
-      - "**.vue"
       - "**.html"
       - "**.css"
       - "frontend/package.json"

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

@@ -14,7 +14,6 @@ on:
       - "**.mjs"
       - "**.cjs"
       - "**.ts"
-      - "**.vue"
       - "frontend/package-lock.json"
   pull_request:
     paths:
@@ -25,7 +24,6 @@ on:
       - "**.mjs"
       - "**.cjs"
       - "**.ts"
-      - "**.vue"
       - "frontend/package-lock.json"
   schedule:
     - cron: "18 2 * * 2"

+ 2 - 2
.github/workflows/release.yml

@@ -116,7 +116,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -250,7 +250,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/"
           Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 3 - 0
.gitignore

@@ -16,6 +16,9 @@ tmp/
 backup/
 bin/
 dist/
+!web/dist/
+web/dist/*
+!web/dist/.gitkeep
 release/
 node_modules/
 

+ 67 - 63
CONTRIBUTING.md

@@ -1,33 +1,33 @@
 # Contributing
 
-Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running on your machine in a few minutes.
+Thanks for taking the time to contribute to 3x-ui. This guide gets a development panel running locally and explains the conventions the project follows so changes land cleanly.
 
 ## Prerequisites
 
-- **Go 1.26+** (the version in `go.mod`)
-- **Node.js 22+** and npm (for the Vue frontend)
+- **Go 1.26+** (the version pinned in `go.mod`)
+- **Node.js 22+** and npm 10+ (for the React frontend)
 - **Git**
-- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux/macOS already ship one; on Windows see below.
+- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
 
 ### Windows: MinGW-w64
 
-`go build` on Windows will fail with `cgo: C compiler "gcc" not found` until you install a GCC toolchain. Two options — pick whichever fits.
+`go build` on Windows fails with `cgo: C compiler "gcc" not found` until a GCC toolchain is installed. Two options — pick whichever fits.
 
 **Option A — standalone zip (fastest, no package manager)**
 
-1. Grab the latest build from **<https://github.com/niXman/mingw-builds-binaries/releases>**. For most setups you want a release named like:
+1. Download the latest build from <https://github.com/niXman/mingw-builds-binaries/releases>. For most setups, pick a release named:
    ```
    x86_64-<version>-release-posix-seh-ucrt-rt_<n>-rev<m>.7z
    ```
-   (64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches the modern Windows defaults.)
+   (64-bit, POSIX threads, SEH exceptions, UCRT runtime — matches modern Windows defaults.)
 2. Extract it somewhere stable, e.g. `C:\mingw64\`.
-3. Add `C:\mingw64\bin` to your **Windows** `PATH` (System Properties → Environment Variables → Path → New).
+3. Add `C:\mingw64\bin` to the **Windows** `PATH` (System Properties → Environment Variables → Path → New).
 4. Open a fresh terminal and confirm:
    ```powershell
    gcc --version
    ```
 
-**Option B — MSYS2 (if you also want a Unix-y shell)**
+**Option B — MSYS2 (when a Unix shell is also useful)**
 
 1. Install MSYS2 from <https://www.msys2.org/>.
 2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
@@ -38,14 +38,14 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
    ```bash
    pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
    ```
-4. Add `C:\msys64\ucrt64\bin` to your Windows `PATH`.
+4. Add `C:\msys64\ucrt64\bin` to the Windows `PATH`.
 5. Verify with `gcc --version` in a fresh terminal.
 
-After either, `go build ./...` and `go run .` work normally.
+After either path, `go build ./...` and `go run .` work normally.
 
-> Why MinGW-w64 over MSVC: `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain doesn't lock you into a Visual Studio install. If you already have Visual Studio Build Tools installed it works too — just make sure `CC=cl` is **not** set in your environment.
+> **Why MinGW-w64 over MSVC:** `mattn/go-sqlite3` officially supports GCC, builds are faster on Windows, and the toolchain does not require a Visual Studio install. If Visual Studio Build Tools are already present that works too — just make sure `CC=cl` is **not** set in the environment.
 
-The Linux SQLite cross-build from Windows (or vice versa) needs an extra cross-compiler — out of scope here; build natively on the target OS.
+Cross-building the Linux SQLite target from Windows (or vice versa) requires a separate cross-compiler and is out of scope here; build natively on the target OS.
 
 ## First-time setup
 
@@ -65,7 +65,7 @@ npm run build
 cd ..
 ```
 
-`.env.example` ships with sane defaults that point the database, logs, and xray binary at the local `x-ui/` folder so nothing escapes the project directory:
+`.env.example` ships with defaults that keep the database, logs, and xray binary inside the local `x-ui/` folder so nothing escapes the project directory:
 
 ```
 XUI_DEBUG=true
@@ -74,7 +74,7 @@ XUI_LOG_FOLDER=x-ui
 XUI_BIN_FOLDER=x-ui
 ```
 
-You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` / `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows you also want `wintun.dll` if you plan to test TUN inbounds.
+Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
 
 ## Running
 
@@ -82,11 +82,11 @@ You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-lin
 go run .
 ```
 
-Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. You will be prompted to change the credentials on first login.
+Open [http://localhost:2053](http://localhost:2053) and log in with `admin` / `admin`. Credentials must be changed on first login.
 
 ### Inside VS Code
 
-The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if it is missing):
+The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy from the snippet below if absent):
 
 ```jsonc
 {
@@ -113,93 +113,97 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
 
 ## Working on the frontend
 
-The panel UI is a Vue 3 + Ant Design Vue 4 app under `frontend/`. A few things worth knowing before you dive in.
+The panel UI is a **React 19 + Ant Design 6 + TypeScript** app under `frontend/`, built with Vite 8. The sections below cover the architecture, the conventions, and the two dev workflows.
 
-### Architecture in one paragraph
+### Architecture
 
-It's a **multi-page app**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/sub`, `/panel/api-docs`, plus `login`) has its own HTML entry under `frontend/*.html` and its own bootstrap in `src/entries/<page>.js`. Vite builds them into `web/dist/`, and the Go binary embeds that directory at compile time with `embed.FS`. Each navigation triggers a real document load — but each page's bundle is small, so it stays snappy. There's no Vue Router and no central store; Vuex/Pinia were rejected as overkill for the panel's surface area.
+The frontend is a **multi-page application**, not a SPA. Every panel route (`/panel`, `/panel/inbounds`, `/panel/clients`, `/panel/xray`, `/panel/settings`, `/panel/nodes`, `/panel/api-docs`, `/panel/sub`, plus `login`) has its own HTML entry in `frontend/*.html` and its own bootstrap in `src/entries/<page>.tsx`. Vite emits each entry into `web/dist/`, and the Go binary embeds that directory at compile time via `embed.FS`. Each panel navigation is a real document load, but every per-page bundle is small enough to keep the experience responsive. There is no React Router and no global store; the surface area does not justify either.
 
 ### State and data flow
 
-- **No global store.** State lives where it's used. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is on the same box and responses are cheap.
-- **Composables** in `src/composables/` carry reactive logic worth sharing inside a page (theme switching, status polling, node lists). Reach for one before adding a new global.
-- **Domain classes** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. The Vue components stay dumb; they ask the model "what's my link?" and render the answer.
-- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, which is a thin Axios wrapper with CSRF, response toast handling, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts.
+- **No global store.** State lives in the page that owns it. Cross-page data (settings, current user, theme) is re-fetched on each page load — the backend is local and responses are inexpensive.
+- **Hooks** in `src/hooks/` encapsulate reactive logic worth sharing inside a page (`useTheme`, `useStatus`, `useNodes`, `useWebSocket`, `useDatepicker`, …). Prefer extending an existing hook over introducing a new global.
+- **Domain models** in `src/models/` (`Inbound`, `DBInbound`, `Outbound`, `Status`, …) own the protocol-specific logic — link generation, settings JSON shape, TLS/Reality stream handling. React components stay declarative; they ask the model "what is my link?" and render the answer.
+- **HTTP** goes through `src/utils/index.js`'s `HttpUtil`, a thin Axios wrapper that handles CSRF, response toasts, and a `silent: true` opt-out for bulk operations that would otherwise spam toasts. The Axios setup itself lives in `src/api/axios-init.js`.
 
 ### i18n
 
-Locale strings live in `web/translation/<locale>.json`, not under `frontend/`. The Go side embeds the same JSON and serves it to both backend templates and `vue-i18n`. When you add a new English key, add it to **every** non-English locale too — missing keys don't fail the build, they just render the raw key in the UI.
+Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/`. The Go binary embeds the same JSON and serves it to both backend templates and `react-i18next` (initialized in `src/i18n/react.ts`). When a new English key is added it must also land in **every** non-English locale — missing keys do not break the build, they just render the raw key in the UI.
 
 ### Two dev workflows
 
-| When you want… | Use |
-|----------------|-----|
-| To iterate on UI tweaks fast | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
-| To test what users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle either embedded (release mode) or from disk (debug mode). |
+| Goal | Command |
+|------|---------|
+| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and `/api/*` to the Go panel on `:2053`). Start the Go panel first. |
+| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
 
-The Vite dev proxy auto-rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, etc.) to the matching Vite-served HTML, so the navigation feels identical to prod without round-tripping through Go. The route allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — if you add a new page, register it there too.
+The Vite dev proxy rewrites the sidebar's production-style links (`/panel`, `/panel/inbounds`, `/panel/clients`, …) to the matching Vite-served HTML, so navigation behaves identically to production without round-tripping through Go. The allowlist lives in `MIGRATED_ROUTES` in `vite.config.js` — register every new page there.
 
-> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML out of the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names → blank page with 404s in the browser console. Always restart `go run .` after a frontend rebuild.
+> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
 
 ### Adding a new page
 
-1. Create `frontend/<page>.html` (copy an existing one and adjust the title + the imported entry).
-2. Create `src/entries/<page>.js` — `createApp(Page).use(antd).use(i18n).mount('#app')`.
-3. Create the page component under `src/pages/<page>/<Page>.vue` (kebab-case folder, PascalCase component).
+1. Create `frontend/<page>.html` (copy an existing entry and adjust the title and the imported `<script type="module" src="/src/entries/<page>.tsx">`).
+2. Create `src/entries/<page>.tsx` — mount the page with `createRoot(document.getElementById('app')!).render(...)`, wrapped in the shared `ConfigProvider` for AntD theming and i18n.
+3. Create the page component under `src/pages/<page>/<Page>.tsx` (kebab-case folder, PascalCase component).
 4. Register the entry in `rollupOptions.input` inside `vite.config.js`.
 5. If the page is reachable from the sidebar at `/panel/<route>`, add `<route>` to `MIGRATED_ROUTES` so dev-mode navigation works.
-6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in prod.
+6. Wire a Go controller route that calls `serveDistPage(c, "<page>.html")` to serve the embedded HTML in production.
 
 ### Conventions
 
-- **Ant Design Vue** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back as ugly + bloated. Small targeted UX tweaks beat sweeping rewrites; if a section *really* needs new visual language, raise it first.
-- **Composition API** (`<script setup>`) everywhere. Options API survives only in components nobody has touched yet.
-- **No `//` line comments** in committed JS/Vue. HTML `<!-- ... -->` is fine for template structure. Identifiers should carry the meaning; if you need a comment to explain *what* code does, rename the variable. Comments are for the *why* and only when surprising.
-- **Persian / Arabic users matter.** RTL is supported via `ConfigProvider` + `dir="rtl"`. When you write Persian text in toasts or labels, keep prose clean — isolate code/identifiers on their own lines so the RTL reading flows.
-- **Don't break links.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.vue` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever you touch URL generation, fallback projection, or TLS handling.
+- **TypeScript strict mode** — all new code in `.ts` / `.tsx`. Run `npm run typecheck` (`tsc --noEmit`) before pushing. The path alias `@/*` resolves to `src/*`.
+- **Ant Design 6** is the only UI kit — no Tailwind, no shadcn. A previous attempt to migrate was rolled back. Small, targeted UX tweaks beat sweeping rewrites; raise broader visual changes for discussion before implementing.
+- **Function components + hooks** everywhere. No class components.
+- **No `//` line comments** in committed JS/TS/Vue/Go. HTML `<!-- ... -->` is fine for template structure. Names should carry the meaning; rename rather than annotate. Comments are reserved for the *why*, and only when the reason is surprising.
+- **RTL is a first-class concern.** Persian and Arabic users matter — RTL is enabled through AntD's `ConfigProvider direction="rtl"`. When writing Persian text in toasts or labels, isolate code identifiers on their own lines so RTL reading flows.
+- **Do not break link generation.** Share-link generation has two paths: the **inbounds page** (`InboundsPage.tsx` → `checkFallback()`) and the **clients page** (`/panel/api/clients/subLinks/:subId` → backend `GetSubs`). Exercise both whenever URL generation, fallback projection, or TLS handling changes.
+- **Vite is pinned** to `8.0.13`. Do not bump to `8.0.14+` — the esbuild dep-optimizer in those builds breaks i18n loading in dev mode.
 
 ### Project layout
 
 ```
 frontend/
-├── *.html                — Vite entry HTML, one per panel route
-├── eslint.config.js      — ESLint 10 flat config (vue3-recommended)
+├── *.html                 — Vite entry HTML, one per panel route
+├── tsconfig.json          — strict, jsx: "react-jsx", paths "@/*" → "src/*"
+├── eslint.config.js       — ESLint 10 flat config (@eslint/js + typescript-eslint + react-hooks)
 ├── vite.config.js
 └── src/
-    ├── entries/          — per-page bootstrap (createApp + mount)
-    ├── pages/            — one folder per route (index, login, inbounds, clients, xray, settings, sub, api-docs)
-    ├── components/       — cross-page Vue components (DateTimePicker, FinalMaskForm, …)
-    ├── composables/      — reusable reactive logic (useTheme, useStatus, useNodeList, …)
-    ├── api/              — Axios setup + CSRF interceptor + WebSocket client
-    ├── i18n/             — vue-i18n bootstrap (the JSON lives in web/translation/)
-    ├── models/           — Inbound, DBInbound, Outbound, Status, reality-targets, …
-    └── utils/            — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
+    ├── entries/           — per-page bootstrap (createRoot + render)
+    ├── pages/             — one folder per route (index, login, inbounds, clients, xray, nodes, settings, api-docs, sub)
+    ├── components/        — cross-page React components (AppSidebar, DateTimePicker, FinalMaskForm, JsonEditor, …)
+    ├── hooks/             — reusable hooks (useTheme, useStatus, useNodes, useWebSocket, useDatepicker, …)
+    ├── api/               — Axios setup + CSRF interceptor + WebSocket client
+    ├── i18n/              — react-i18next bootstrap (JSON lives in web/translation/)
+    ├── models/            — Inbound, DBInbound, Outbound, Status, reality-targets, …
+    ├── styles/            — shared CSS (page-cards, …)
+    └── utils/             — HttpUtil, ObjectUtil, LanguageManager, RandomUtil, SizeFormatter, …
 ```
 
-Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/README.md`](frontend/README.md).
+For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/README.md).
 
 ## Project layout
 
-| Path | What lives there |
-|------|------------------|
+| Path | Contents |
+|------|----------|
 | `main.go` | Process entry point, CLI subcommands, signal handling |
-| `web/` | Gin HTTP server, controllers, services, embedded frontend |
-| `frontend/` | Vue 3 + Ant Design source for the panel UI |
+| `web/` | Gin HTTP server, controllers, services, embedded frontend assets |
+| `frontend/` | React + Ant Design 6 + TypeScript source for the panel UI |
 | `database/` | GORM models, migrations, seeders (SQLite / PostgreSQL) |
-| `xray/` | Xray-core process lifecycle + gRPC API client |
+| `xray/` | Xray-core process lifecycle and gRPC API client |
 | `sub/` | Subscription endpoints (raw, JSON, Clash) |
-| `config/` | Environment-var helpers, paths, defaults |
+| `config/` | Environment-variable helpers, paths, defaults |
 | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
 
 ## Sending a pull request
 
 1. Branch off `main` (e.g. `feat/short-description`).
 2. Keep the diff focused — separate refactors from feature work.
-3. Run the relevant builds before pushing:
+3. Run the relevant checks before pushing:
    - `go build ./...`
-   - `go test ./...` (if you touched Go code)
-   - `cd frontend && npm run build` (if you touched the Vue side)
-4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*.
+   - `go test ./...` (when Go code changed)
+   - `cd frontend && npm run typecheck && npm run lint && npm run build` (when the frontend changed)
+4. Commit messages follow the existing pattern in `git log` — `<area>: short imperative summary`, then a body explaining the *why*. Conventional-commit prefixes (`feat`, `fix`, `refactor`, `chore`, `style`, `docs`) are encouraged.
 5. Open the PR against `main` with a brief description of what changed and how to test it.
 
 ## Useful environment variables
@@ -210,7 +214,7 @@ Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/READ
 | `XUI_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
 | `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
 | `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
-| `XUI_BIN_FOLDER` | `bin` | Where the xray binary + geo files + xray `config.json` live |
+| `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
 | `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
 | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
 
@@ -219,4 +223,4 @@ Lint with `cd frontend && npm run lint`. The deeper reference is [`frontend/READ
 - Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)
 - General questions and ideas: [GitHub Discussions](https://github.com/MHSanaei/3x-ui/discussions)
 
-Before filing a bug, please include your OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.
+Before filing a bug, include the OS, Go version, panel version (`/panel/api/server/status` or the dashboard footer), and the relevant excerpt from `x-ui/3xui.log`.

+ 1 - 1
DockerInit.sh

@@ -27,7 +27,7 @@ case $1 in
 esac
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.5.9/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 0 - 1
database/db.go

@@ -49,7 +49,6 @@ func Dialect() string {
 	return db.Dialector.Name()
 }
 
-
 const (
 	defaultUsername = "admin"
 	defaultPassword = "admin"

+ 18 - 18
database/model/model.go

@@ -16,16 +16,16 @@ type Protocol string
 
 // Protocol constants for different Xray inbound protocols
 const (
-	VMESS        Protocol = "vmess"
-	VLESS        Protocol = "vless"
-	Tunnel       Protocol = "tunnel"
-	HTTP         Protocol = "http"
-	Trojan       Protocol = "trojan"
-	Shadowsocks  Protocol = "shadowsocks"
-	Mixed        Protocol = "mixed"
-	WireGuard    Protocol = "wireguard"
-	Hysteria     Protocol = "hysteria"
-	Hysteria2    Protocol = "hysteria2"
+	VMESS       Protocol = "vmess"
+	VLESS       Protocol = "vless"
+	Tunnel      Protocol = "tunnel"
+	HTTP        Protocol = "http"
+	Trojan      Protocol = "trojan"
+	Shadowsocks Protocol = "shadowsocks"
+	Mixed       Protocol = "mixed"
+	WireGuard   Protocol = "wireguard"
+	Hysteria    Protocol = "hysteria"
+	Hysteria2   Protocol = "hysteria2"
 )
 
 // IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -144,7 +144,7 @@ type ApiToken struct {
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
 	Token     string `json:"token" gorm:"not null"`
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
-	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }
 
 // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
@@ -275,8 +275,8 @@ type Node struct {
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	DepletedCount int `json:"depletedCount" gorm:"-"`
 
-	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
-	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
+	CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 }
 
 type CustomGeoResource struct {
@@ -287,8 +287,8 @@ type CustomGeoResource struct {
 	LocalPath     string `json:"localPath" gorm:"column:local_path"`
 	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
 	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
-	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
-	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
+	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
+	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
 }
 
 type ClientReverse struct {
@@ -333,8 +333,8 @@ type ClientRecord struct {
 	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
 	Comment    string `json:"comment"`
 	Reset      int    `json:"reset" gorm:"default:0"`
-	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime"`
-	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime"`
+	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt  int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
 }
 
 func (ClientRecord) TableName() string { return "clients" }
@@ -374,7 +374,7 @@ type ClientInbound struct {
 	ClientId     int    `json:"clientId" gorm:"primaryKey;column:client_id;index"`
 	InboundId    int    `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
 	FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
-	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime"`
+	CreatedAt    int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }
 
 func (ClientInbound) TableName() string { return "client_inbounds" }

+ 22 - 14
frontend/README.md

@@ -1,8 +1,8 @@
 # 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`.
+React 19 + Ant Design 6 + TypeScript + Vite 8. Multi-page app — one HTML
+entry per panel route — built into `../web/dist/` and embedded into the
+Go binary via `embed.FS`.
 
 ## Dev
 
@@ -30,44 +30,52 @@ 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
+## Type check and lint
 
 ```sh
+npm run typecheck
 npm run lint
 ```
 
-ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
-plus a few rule overrides for the project's formatting style.
+`tsc --noEmit` against `tsconfig.json` (strict mode, `jsx: "react-jsx"`,
+`@/*` → `src/*` alias). ESLint 10 with `eslint.config.js` (flat config)
+— `@eslint/js` recommended plus `typescript-eslint` and
+`eslint-plugin-react-hooks` rules.
 
 ## Layout
 
 ```
 frontend/
 ├── *.html                 # Vite entry HTML, one per panel route
+├── tsconfig.json
 ├── eslint.config.js
 ├── vite.config.js
 └── src/
-    ├── entries/           # Per-page bootstrap (createApp + mount)
+    ├── entries/           # Per-page bootstrap (createRoot + render)
     ├── pages/             # One folder per route, each with the page
     │   ├── index/         # component + helpers + sub-components
     │   ├── login/
     │   ├── inbounds/
+    │   ├── clients/
     │   ├── xray/
+    │   ├── nodes/
     │   ├── settings/
+    │   ├── api-docs/
     │   └── 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/)
+    ├── components/        # Cross-page React components
+    ├── hooks/             # Reusable hooks (useTheme, useWebSocket, …)
+    ├── api/               # Axios setup, CSRF interceptor, WebSocket
+    ├── i18n/              # react-i18next init (locales live in web/translation/)
     ├── models/            # Inbound, Outbound, Status, … domain classes
+    ├── styles/            # Shared CSS modules (page-cards, …)
     └── 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.
+1. Add `frontend/<page>.html` referencing `/src/entries/<page>.tsx`.
+2. Add `src/entries/<page>.tsx` that imports the page component and
+   mounts it with `createRoot(...).render(...)`.
 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

+ 1 - 1
frontend/api-docs.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/api-docs.js"></script>
+    <script type="module" src="/src/entries/api-docs.tsx"></script>
   </body>
 </html>

+ 1 - 1
frontend/clients.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/clients.js"></script>
+    <script type="module" src="/src/entries/clients.tsx"></script>
   </body>
 </html>

+ 44 - 31
frontend/eslint.config.js

@@ -1,21 +1,16 @@
 import js from '@eslint/js';
-import vue from 'eslint-plugin-vue';
-import vueParser from 'vue-eslint-parser';
+import tseslint from 'typescript-eslint';
+import reactHooks from 'eslint-plugin-react-hooks';
 import globals from 'globals';
 
 export default [
   { ignores: ['node_modules/**', '../web/dist/**'] },
   js.configs.recommended,
-  ...vue.configs['flat/recommended'],
   {
-    files: ['**/*.{js,vue}'],
+    files: ['**/*.js'],
     languageOptions: {
       ecmaVersion: 2022,
       sourceType: 'module',
-      parser: vueParser,
-      parserOptions: {
-        ecmaFeatures: { jsx: false },
-      },
       globals: {
         ...globals.browser,
         ...globals.node,
@@ -29,30 +24,48 @@ export default [
       }],
       'no-empty': ['error', { allowEmptyCatch: true }],
       'no-case-declarations': 'off',
+    },
+  },
+  ...tseslint.configs.recommended.map((config) => ({
+    ...config,
+    files: ['**/*.{ts,tsx}'],
+  })),
+  {
+    files: ['**/*.{ts,tsx}'],
+    plugins: {
+      'react-hooks': reactHooks,
+    },
+    languageOptions: {
+      ecmaVersion: 2022,
+      sourceType: 'module',
+      globals: {
+        ...globals.browser,
+      },
+    },
+    rules: {
+      ...reactHooks.configs.recommended.rules,
+      '@typescript-eslint/no-unused-vars': ['warn', {
+        argsIgnorePattern: '^_',
+        varsIgnorePattern: '^_',
+        caughtErrorsIgnorePattern: '^_',
+      }],
+      'no-empty': ['error', { allowEmptyCatch: true }],
 
-      // 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',
+      // react-hooks v7 introduces several new rules driven by the React
+      // Compiler. The migration uses several legitimate patterns those
+      // rules flag (initial-fetch in useEffect, dirty-check derived
+      // state, `Date.now()` inside derive helpers, inline arrow event
+      // handlers, in-place mutation of imported Outbound class
+      // instances in the OutboundFormModal). We're not running the
+      // compiler, so the memoization-preservation warnings have no
+      // effect on runtime — turning them off until the codebase
+      // stabilises.
+      'react-hooks/set-state-in-effect': 'off',
+      'react-hooks/purity': 'off',
+      'react-hooks/react-compiler': 'off',
+      'react-hooks/preserve-manual-memoization': 'off',
+      'react-hooks/immutability': 'off',
+      'react-hooks/refs': 'off',
     },
   },
 ];

+ 1 - 1
frontend/inbounds.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/inbounds.js"></script>
+    <script type="module" src="/src/entries/inbounds.tsx"></script>
   </body>
 </html>

+ 1 - 1
frontend/index.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/index.js"></script>
+    <script type="module" src="/src/entries/index.tsx"></script>
   </body>
 </html>

+ 1 - 1
frontend/login.html

@@ -9,6 +9,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/login.js"></script>
+    <script type="module" src="/src/entries/login.tsx"></script>
   </body>
 </html>

+ 1 - 1
frontend/nodes.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/nodes.js"></script>
+    <script type="module" src="/src/entries/nodes.tsx"></script>
   </body>
 </html>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1093 - 158
frontend/package-lock.json


+ 22 - 19
frontend/package.json

@@ -1,9 +1,9 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.0.3",
+  "version": "0.1.0",
   "type": "module",
-  "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
+  "description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
   "engines": {
     "node": ">=22.0.0",
     "npm": ">=10.0.0"
@@ -12,32 +12,35 @@
     "dev": "vite",
     "build": "vite build",
     "preview": "vite preview",
-    "lint": "eslint src"
+    "lint": "eslint src",
+    "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@ant-design/icons-vue": "^7.0.1",
+    "@ant-design/icons": "^6.2.3",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "ant-design-vue": "^4.2.6",
-    "axios": "^1.7.9",
+    "antd": "^6.4.3",
+    "axios": "^1.16.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.20",
+    "i18next": "^26.2.0",
     "otpauth": "^9.5.1",
-    "qs": "^6.13.1",
-    "vue": "^3.5.13",
-    "vue-i18n": "^11.1.4",
-    "vue3-persian-datetime-picker": "^1.2.2"
+    "persian-calendar-suite": "^1.5.5",
+    "qs": "^6.15.2",
+    "react": "^19.2.6",
+    "react-dom": "^19.2.6",
+    "react-i18next": "^17.0.8"
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
-    "@vitejs/plugin-vue": "^6.0.6",
-    "eslint": "^10.3.0",
-    "eslint-plugin-vue": "^10.9.1",
+    "@types/react": "^19.2.15",
+    "@types/react-dom": "^19.2.3",
+    "@vitejs/plugin-react": "^6.0.2",
+    "eslint": "^10.4.0",
+    "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.6.0",
-    "vite": "^8.0.11",
-    "vue-eslint-parser": "^10.4.0"
-  },
-  "overrides": {
-    "moment-jalaali": "^0.10.4"
+    "typescript": "^6.0.3",
+    "typescript-eslint": "^8.59.4",
+    "vite": "8.0.13"
   }
-}
+}

+ 1 - 1
frontend/settings.html

@@ -8,6 +8,6 @@
   <body>
     <div id="message"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/settings.js"></script>
+    <script type="module" src="/src/entries/settings.tsx"></script>
   </body>
 </html>

+ 287 - 0
frontend/src/components/AppSidebar.css

@@ -0,0 +1,287 @@
+.ant-sidebar > .ant-layout-sider {
+  position: sticky;
+  top: 0;
+  height: 100vh;
+  align-self: flex-start;
+}
+
+.sider-brand,
+.drawer-brand {
+  font-weight: 600;
+  font-size: 18px;
+  letter-spacing: 0.5px;
+  color: rgba(0, 0, 0, 0.88);
+}
+
+.sider-brand {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  padding: 14px 14px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+  user-select: none;
+}
+
+.sider-brand-collapsed {
+  justify-content: center;
+  font-size: 16px;
+  padding: 14px 4px;
+  letter-spacing: 0;
+}
+
+.brand-block {
+  display: inline-flex;
+  flex-direction: column;
+  align-items: center;
+  min-width: 0;
+  line-height: 1.1;
+}
+
+.brand-text {
+  display: block;
+}
+
+.brand-version {
+  display: block;
+  width: 100%;
+  text-align: center;
+  font-size: 10px;
+  font-weight: 500;
+  letter-spacing: 0;
+  opacity: 0.6;
+  margin-top: 2px;
+}
+
+.sider-brand-collapsed .brand-block {
+  align-items: center;
+  flex: 0 0 auto;
+}
+
+.brand-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 2px;
+  flex-shrink: 0;
+}
+
+.sidebar-donate {
+  background: transparent;
+  border: none;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  color: rgba(0, 0, 0, 0.75);
+  text-decoration: none;
+  flex-shrink: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.sidebar-donate:hover,
+.sidebar-donate:focus-visible {
+  background-color: rgba(236, 72, 153, 0.12);
+  color: #ec4899;
+  transform: scale(1.08);
+  outline: none;
+}
+
+.sidebar-donate .anticon {
+  font-size: 16px;
+}
+
+.sidebar-theme-cycle {
+  background: transparent;
+  border: none;
+  width: 30px;
+  height: 30px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  color: rgba(0, 0, 0, 0.75);
+  padding: 0;
+  flex-shrink: 0;
+  transition: background-color 0.2s, transform 0.15s, color 0.2s;
+}
+
+.sidebar-theme-cycle:hover,
+.sidebar-theme-cycle:focus-visible {
+  background-color: rgba(64, 150, 255, 0.1);
+  color: #4096ff;
+  transform: scale(1.08);
+  outline: none;
+}
+
+.sidebar-theme-cycle svg {
+  width: 16px;
+  height: 16px;
+}
+
+.drawer-header-actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+}
+
+.drawer-handle {
+  position: fixed;
+  top: 12px;
+  left: 12px;
+  z-index: 1100;
+  background: rgba(0, 0, 0, 0.55);
+  color: #fff;
+  border: none;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  cursor: pointer;
+  display: none;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+}
+
+.drawer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 14px 16px;
+  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.drawer-close {
+  background: transparent;
+  border: none;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  font-size: 16px;
+  color: rgba(0, 0, 0, 0.65);
+}
+
+.drawer-close:hover,
+.drawer-close:focus-visible {
+  background: rgba(128, 128, 128, 0.18);
+}
+
+.drawer-menu .ant-menu-item {
+  height: 48px;
+  line-height: 48px;
+  margin: 0;
+  border-radius: 0;
+}
+
+.drawer-menu .ant-menu-item .anticon {
+  font-size: 16px;
+}
+
+.drawer-utility {
+  margin-top: auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+.ant-sidebar > .ant-layout-sider .ant-layout-sider-children {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.sider-nav {
+  flex: 1 1 auto;
+  overflow-y: auto;
+  overflow-x: hidden;
+  min-height: 0;
+}
+
+.sider-utility {
+  flex: 0 0 auto;
+  border-top: 1px solid rgba(128, 128, 128, 0.15);
+}
+
+@media (max-width: 768px) {
+  .drawer-handle {
+    display: inline-flex;
+  }
+
+  .ant-sidebar > .ant-layout-sider .ant-layout-sider-children,
+  .ant-sidebar > .ant-layout-sider .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;
+  }
+}
+
+body.dark .drawer-brand,
+body.dark .sider-brand {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+html[data-theme='ultra-dark'] .drawer-brand,
+html[data-theme='ultra-dark'] .sider-brand {
+  color: #ffffff;
+}
+
+body.dark .drawer-close {
+  color: rgba(255, 255, 255, 0.75);
+}
+
+html[data-theme='ultra-dark'] .drawer-close {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+body.dark .sidebar-theme-cycle {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+html[data-theme='ultra-dark'] .sidebar-theme-cycle {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+body.dark .sidebar-donate {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+html[data-theme='ultra-dark'] .sidebar-donate {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+body.dark .ant-drawer .ant-drawer-content,
+body.dark .ant-drawer .ant-drawer-body {
+  background: #252526 !important;
+}
+
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
+html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
+  background: #0a0a0a !important;
+}
+
+.sider-nav .ant-menu-item-selected,
+.sider-utility .ant-menu-item-selected,
+.drawer-menu .ant-menu-item-selected {
+  background-color: rgba(64, 150, 255, 0.2) !important;
+  color: #4096ff !important;
+}
+
+.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
+.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
+.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
+.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
+  background-color: rgba(64, 150, 255, 0.1) !important;
+  color: #4096ff !important;
+}

+ 290 - 0
frontend/src/components/AppSidebar.tsx

@@ -0,0 +1,290 @@
+import { useCallback, useMemo, useState } from 'react';
+import type { ComponentType } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Drawer, Layout, Menu } from 'antd';
+import type { MenuProps } from 'antd';
+import {
+  ApiOutlined,
+  ClusterOutlined,
+  CloseOutlined,
+  DashboardOutlined,
+  HeartOutlined,
+  LogoutOutlined,
+  MenuOutlined,
+  SettingOutlined,
+  TeamOutlined,
+  ToolOutlined,
+  UserOutlined,
+} from '@ant-design/icons';
+
+import { HttpUtil } from '@/utils';
+import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
+import './AppSidebar.css';
+
+const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
+const DONATE_URL = 'https://donate.sanaei.dev/';
+
+interface AppSidebarProps {
+  basePath?: string;
+  requestUri?: string;
+}
+
+type IconName = 'dashboard' | 'user' | 'team' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
+
+const iconByName: Record<IconName, ComponentType> = {
+  dashboard: DashboardOutlined,
+  user: UserOutlined,
+  team: TeamOutlined,
+  setting: SettingOutlined,
+  tool: ToolOutlined,
+  cluster: ClusterOutlined,
+  logout: LogoutOutlined,
+  apidocs: ApiOutlined,
+};
+
+function readCollapsed(): boolean {
+  try {
+    return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
+  } catch {
+    return false;
+  }
+}
+
+function DonateButton({ ariaLabel }: { ariaLabel: string }) {
+  return (
+    <a
+      href={DONATE_URL}
+      target="_blank"
+      rel="noopener noreferrer"
+      className="sidebar-donate"
+      aria-label={ariaLabel}
+      title={ariaLabel}
+    >
+      <HeartOutlined />
+    </a>
+  );
+}
+
+function ThemeCycleButton({ id, isDark, isUltra, onCycle, ariaLabel }: {
+  id: string;
+  isDark: boolean;
+  isUltra: boolean;
+  onCycle: () => void;
+  ariaLabel: string;
+}) {
+  return (
+    <button
+      id={id}
+      type="button"
+      className="sidebar-theme-cycle"
+      aria-label={ariaLabel}
+      title={ariaLabel}
+      onClick={onCycle}
+    >
+      {!isDark ? (
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+          <circle cx="12" cy="12" r="4" />
+          <path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
+        </svg>
+      ) : !isUltra ? (
+        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+        </svg>
+      ) : (
+        <svg viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
+          <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
+          <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
+        </svg>
+      )}
+    </button>
+  );
+}
+
+export default function AppSidebar({ basePath = '', requestUri = '' }: AppSidebarProps) {
+  const { t } = useTranslation();
+  const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
+
+  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
+  const [drawerOpen, setDrawerOpen] = useState(false);
+
+  const prefix = basePath.startsWith('/') ? basePath : `/${basePath || ''}`;
+  const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
+  const panelVersion = window.X_UI_CUR_VER || '';
+
+  const tabs = useMemo<{ key: string; icon: IconName; title: string }[]>(() => [
+    { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
+    { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
+    { key: `${prefix}panel/clients`, icon: 'team', title: t('menu.clients') },
+    { 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}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
+    { key: 'logout', icon: 'logout', title: t('logout') },
+  ], [prefix, t]);
+
+  const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
+  const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
+
+  const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
+    items.map((tab) => {
+      const Icon = iconByName[tab.icon];
+      return {
+        key: tab.key,
+        icon: <Icon />,
+        label: tab.title,
+      };
+    }),
+  []);
+
+  const openLink = useCallback(async (key: string) => {
+    if (key === 'logout') {
+      await HttpUtil.post('/logout');
+      window.location.href = basePath || '/';
+      return;
+    }
+    if (key.startsWith('http')) {
+      window.open(key);
+    } else {
+      window.location.href = key;
+    }
+  }, [basePath]);
+
+  const onMenuClick = useCallback<NonNullable<MenuProps['onClick']>>(({ key }) => {
+    openLink(String(key));
+  }, [openLink]);
+
+  const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
+    if (type === 'clickTrigger') {
+      localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
+      setCollapsed(isCollapsed);
+    }
+  }, []);
+
+  const cycleTheme = useCallback((id: string) => {
+    pauseAnimationsUntilLeave(id);
+    if (!isDark) {
+      toggleTheme();
+      if (isUltra) toggleUltra();
+    } else if (!isUltra) {
+      toggleUltra();
+    } else {
+      toggleUltra();
+      toggleTheme();
+    }
+  }, [isDark, isUltra, toggleTheme, toggleUltra]);
+
+  return (
+    <div className="ant-sidebar">
+      <Layout.Sider
+        theme={currentTheme}
+        collapsible
+        collapsed={collapsed}
+        breakpoint="md"
+        onCollapse={onSiderCollapse}
+      >
+        <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
+          <div className="brand-block">
+            <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
+            {!collapsed && panelVersion && (
+              <span className="brand-version">v{panelVersion}</span>
+            )}
+          </div>
+          {!collapsed && (
+            <div className="brand-actions">
+              <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
+              <ThemeCycleButton
+                id="theme-cycle"
+                isDark={isDark}
+                isUltra={isUltra}
+                onCycle={() => cycleTheme('theme-cycle')}
+                ariaLabel={t('menu.theme')}
+              />
+            </div>
+          )}
+        </div>
+        <Menu
+          theme={currentTheme}
+          mode="inline"
+          selectedKeys={[requestUri]}
+          className="sider-nav"
+          items={toMenuItems(navItems)}
+          onClick={onMenuClick}
+        />
+        <Menu
+          theme={currentTheme}
+          mode="inline"
+          selectedKeys={[requestUri]}
+          className="sider-utility"
+          items={toMenuItems(utilItems)}
+          onClick={onMenuClick}
+        />
+      </Layout.Sider>
+
+      <Drawer
+        placement="left"
+        closable={false}
+        open={drawerOpen}
+        rootClassName={currentTheme}
+        size="min(82vw, 320px)"
+        styles={{
+          wrapper: { padding: 0 },
+          body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' },
+          header: { display: 'none' },
+        }}
+        onClose={() => setDrawerOpen(false)}
+      >
+        <div className="drawer-header">
+          <div className="brand-block">
+            <span className="drawer-brand">3X-UI</span>
+            {panelVersion && <span className="brand-version">v{panelVersion}</span>}
+          </div>
+          <div className="drawer-header-actions">
+            <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
+            <ThemeCycleButton
+              id="theme-cycle-drawer"
+              isDark={isDark}
+              isUltra={isUltra}
+              onCycle={() => cycleTheme('theme-cycle-drawer')}
+              ariaLabel={t('menu.theme')}
+            />
+            <button
+              className="drawer-close"
+              type="button"
+              aria-label={t('close')}
+              onClick={() => setDrawerOpen(false)}
+            >
+              <CloseOutlined />
+            </button>
+          </div>
+        </div>
+        <Menu
+          theme={currentTheme}
+          mode="inline"
+          selectedKeys={[requestUri]}
+          className="drawer-menu drawer-nav"
+          items={toMenuItems(navItems)}
+          onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
+        />
+        <Menu
+          theme={currentTheme}
+          mode="inline"
+          selectedKeys={[requestUri]}
+          className="drawer-menu drawer-utility"
+          items={toMenuItems(utilItems)}
+          onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}
+        />
+      </Drawer>
+
+      {!drawerOpen && (
+        <button
+          className="drawer-handle"
+          type="button"
+          aria-label={t('menu.dashboard')}
+          onClick={() => setDrawerOpen(true)}
+        >
+          <MenuOutlined />
+        </button>
+      )}
+    </div>
+  );
+}

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

@@ -1,432 +0,0 @@
-<script setup>
-import { computed, ref } from 'vue';
-import { useI18n } from 'vue-i18n';
-import {
-  DashboardOutlined,
-  UserOutlined,
-  TeamOutlined,
-  SettingOutlined,
-  ToolOutlined,
-  ClusterOutlined,
-  LogoutOutlined,
-  CloseOutlined,
-  MenuOutlined,
-  ApiOutlined,
-} from '@ant-design/icons-vue';
-
-import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
-import { HttpUtil } from '@/utils';
-
-const { t } = useI18n();
-
-const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
-
-const props = defineProps({
-  basePath: { type: String, default: '' },
-  // Current request URI so the matching menu item highlights.
-  requestUri: { type: String, default: '' },
-});
-
-
-const iconByName = {
-  dashboard: DashboardOutlined,
-  user: UserOutlined,
-  team: TeamOutlined,
-  setting: SettingOutlined,
-  tool: ToolOutlined,
-  cluster: ClusterOutlined,
-  logout: LogoutOutlined,
-  apidocs: ApiOutlined,
-};
-
-const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
-
-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/clients`, icon: 'team', title: t('menu.clients') },
-  { 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}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
-  { key: 'logout', icon: 'logout', title: t('logout') },
-]);
-
-const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
-const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
-const activeTab = ref([props.requestUri]);
-const drawerOpen = ref(false);
-const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
-const drawerWidth = 'min(82vw, 320px)';
-
-async function openLink(key) {
-  if (key === 'logout') {
-    await HttpUtil.post('/logout');
-    window.location.href = props.basePath || '/';
-    return;
-  }
-  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;
-}
-
-function cycleTheme() {
-  pauseAnimationsUntilLeave('theme-cycle');
-  if (!theme.isDark) {
-    toggleTheme();
-    if (theme.isUltra) toggleUltra();
-  } else if (!theme.isUltra) {
-    toggleUltra();
-  } else {
-    toggleUltra();
-    toggleTheme();
-  }
-}
-</script>
-
-<template>
-  <div class="ant-sidebar">
-    <a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
-      <div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
-        <span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
-        <button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
-          :title="t('menu.theme')" @click="cycleTheme">
-          <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
-            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-            <circle cx="12" cy="12" r="4" />
-            <path
-              d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
-          </svg>
-          <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
-            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
-          </svg>
-          <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
-            stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-            <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
-            <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
-          </svg>
-        </button>
-      </div>
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
-        @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in navTabs" :key="tab.key">
-          <component :is="iconByName[tab.icon]" />
-          <span>{{ tab.title }}</span>
-        </a-menu-item>
-      </a-menu>
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
-        @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in utilTabs" :key="tab.key">
-          <component :is="iconByName[tab.icon]" />
-          <span>{{ tab.title }}</span>
-        </a-menu-item>
-      </a-menu>
-    </a-layout-sider>
-
-    <a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
-      :wrap-style="{ padding: 0 }" :width="drawerWidth"
-      :body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
-      :header-style="{ display: 'none' }" @close="closeDrawer">
-      <div class="drawer-header">
-        <span class="drawer-brand">3X-UI</span>
-        <div class="drawer-header-actions">
-          <button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
-            :title="t('menu.theme')" @click="cycleTheme">
-            <svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
-              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-              <circle cx="12" cy="12" r="4" />
-              <path
-                d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
-            </svg>
-            <svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
-              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
-            </svg>
-            <svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
-              stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
-              <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
-              <path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
-            </svg>
-          </button>
-          <button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
-            <CloseOutlined />
-          </button>
-        </div>
-      </div>
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
-        @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in navTabs" :key="tab.key">
-          <component :is="iconByName[tab.icon]" />
-          <span>{{ tab.title }}</span>
-        </a-menu-item>
-      </a-menu>
-      <a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
-        @click="({ key }) => openLink(key)">
-        <a-menu-item v-for="tab in utilTabs" :key="tab.key">
-          <component :is="iconByName[tab.icon]" />
-          <span>{{ tab.title }}</span>
-        </a-menu-item>
-      </a-menu>
-    </a-drawer>
-
-    <button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
-      @click="toggleDrawer">
-      <MenuOutlined />
-    </button>
-  </div>
-</template>
-
-<style scoped>
-.ant-sidebar>.ant-layout-sider {
-  position: sticky;
-  top: 0;
-  height: 100vh;
-  align-self: flex-start;
-}
-
-.sider-brand,
-.drawer-brand {
-  font-weight: 600;
-  font-size: 18px;
-  letter-spacing: 0.5px;
-  color: rgba(0, 0, 0, 0.88);
-}
-
-.sider-brand {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-  padding: 14px 14px;
-  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
-  user-select: none;
-}
-
-/* Collapsed sider only has room for the '3X' brand — center it and
- * hide the theme cycle button (which is `v-if`-ed out in template). */
-.sider-brand-collapsed {
-  justify-content: center;
-  font-size: 16px;
-  padding: 14px 4px;
-  letter-spacing: 0;
-}
-
-.brand-text {
-  flex: 1 1 auto;
-}
-
-.sider-brand-collapsed .brand-text {
-  flex: 0 0 auto;
-}
-
-.theme-cycle {
-  background: transparent;
-  border: none;
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  cursor: pointer;
-  color: rgba(0, 0, 0, 0.75);
-  padding: 0;
-  flex-shrink: 0;
-  transition: background-color 0.2s, transform 0.15s, color 0.2s;
-}
-
-.theme-cycle:hover,
-.theme-cycle:focus-visible {
-  background-color: rgba(64, 150, 255, 0.1);
-  color: #4096ff;
-  transform: scale(1.08);
-  outline: none;
-}
-
-.theme-cycle svg {
-  width: 16px;
-  height: 16px;
-}
-
-.drawer-header-actions {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
-}
-
-.drawer-handle {
-  position: fixed;
-  top: 12px;
-  left: 12px;
-  z-index: 1100;
-  background: rgba(0, 0, 0, 0.55);
-  color: #fff;
-  border: none;
-  width: 40px;
-  height: 40px;
-  border-radius: 50%;
-  cursor: pointer;
-  display: none;
-  align-items: center;
-  justify-content: center;
-  font-size: 18px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
-}
-
-.drawer-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 14px 16px;
-  border-bottom: 1px solid rgba(128, 128, 128, 0.15);
-}
-
-.drawer-close {
-  background: transparent;
-  border: none;
-  width: 32px;
-  height: 32px;
-  border-radius: 50%;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  cursor: pointer;
-  font-size: 16px;
-  color: rgba(0, 0, 0, 0.65);
-}
-
-.drawer-close:hover,
-.drawer-close:focus-visible {
-  background: rgba(128, 128, 128, 0.18);
-}
-
-.drawer-menu :deep(.ant-menu-item) {
-  height: 48px;
-  line-height: 48px;
-  margin: 0;
-  border-radius: 0;
-}
-
-.drawer-menu :deep(.ant-menu-item .anticon) {
-  font-size: 16px;
-}
-
-.drawer-utility {
-  margin-top: auto;
-  border-top: 1px solid rgba(128, 128, 128, 0.15);
-}
-
-.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
-  display: flex;
-  flex-direction: column;
-  height: 100%;
-}
-
-.sider-brand {
-  flex: 0 0 auto;
-}
-
-.sider-nav {
-  flex: 1 1 auto;
-  overflow-y: auto;
-  overflow-x: hidden;
-  min-height: 0;
-}
-
-.sider-utility {
-  flex: 0 0 auto;
-  border-top: 1px solid rgba(128, 128, 128, 0.15);
-}
-
-@media (max-width: 768px) {
-  .drawer-handle {
-    display: inline-flex;
-  }
-
-  .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>
-
-<style>
-body.dark .drawer-brand,
-body.dark .sider-brand {
-  color: rgba(255, 255, 255, 0.92);
-}
-
-html[data-theme='ultra-dark'] .drawer-brand,
-html[data-theme='ultra-dark'] .sider-brand {
-  color: #ffffff;
-}
-
-body.dark .drawer-close {
-  color: rgba(255, 255, 255, 0.75);
-}
-
-html[data-theme='ultra-dark'] .drawer-close {
-  color: rgba(255, 255, 255, 0.85);
-}
-
-body.dark .theme-cycle {
-  color: rgba(255, 255, 255, 0.85);
-}
-
-html[data-theme='ultra-dark'] .theme-cycle {
-  color: rgba(255, 255, 255, 0.92);
-}
-
-body.dark .ant-drawer .ant-drawer-content,
-body.dark .ant-drawer .ant-drawer-body {
-  background: #252526 !important;
-}
-
-html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
-html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
-  background: #0a0a0a !important;
-}
-
-.sider-nav .ant-menu-item-selected,
-.sider-utility .ant-menu-item-selected,
-.drawer-menu .ant-menu-item-selected {
-  background-color: rgba(64, 150, 255, 0.2) !important;
-  color: #4096ff !important;
-}
-
-.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
-.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
-.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
-.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
-.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
-.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
-  background-color: rgba(64, 150, 255, 0.1) !important;
-  color: #4096ff !important;
-}
-</style>

+ 52 - 0
frontend/src/components/CustomStatistic.css

@@ -0,0 +1,52 @@
+.ant-statistic-content {
+  font-size: 17px !important;
+  line-height: 1.4 !important;
+  font-weight: 600;
+}
+
+.ant-statistic-content-value,
+.ant-statistic-content-prefix,
+.ant-statistic-content-suffix {
+  font-size: 17px !important;
+}
+
+.ant-statistic-content-prefix {
+  margin-inline-end: 8px !important;
+  opacity: 0.7;
+}
+
+.ant-statistic-content-prefix .anticon {
+  font-size: 17px !important;
+}
+
+.ant-statistic-content-suffix {
+  font-size: 12px !important;
+  opacity: 0.55;
+  margin-inline-start: 4px;
+  font-weight: 500;
+}
+
+.ant-statistic-title {
+  font-size: 11px !important;
+  margin-bottom: 6px !important;
+  letter-spacing: 0.6px;
+  text-transform: uppercase;
+  color: rgba(0, 0, 0, 0.55);
+  font-weight: 500;
+}
+
+body.dark .ant-statistic-content {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+body.dark .ant-statistic-title {
+  color: rgba(255, 255, 255, 0.72);
+}
+
+html[data-theme='ultra-dark'] .ant-statistic-content {
+  color: rgba(255, 255, 255, 0.95);
+}
+
+html[data-theme='ultra-dark'] .ant-statistic-title {
+  color: rgba(255, 255, 255, 0.70);
+}

+ 14 - 0
frontend/src/components/CustomStatistic.tsx

@@ -0,0 +1,14 @@
+import type { ReactNode } from 'react';
+import { Statistic } from 'antd';
+import './CustomStatistic.css';
+
+interface CustomStatisticProps {
+  title?: string;
+  value?: string | number;
+  prefix?: ReactNode;
+  suffix?: ReactNode;
+}
+
+export default function CustomStatistic({ title = '', value = '', prefix, suffix }: CustomStatisticProps) {
+  return <Statistic title={title} value={value} prefix={prefix} suffix={suffix} />;
+}

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

@@ -1,31 +0,0 @@
-<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>

+ 35 - 0
frontend/src/components/DateTimePicker.css

@@ -0,0 +1,35 @@
+.jdp-wrap {
+  width: 100%;
+}
+
+.jdp-wrap > * {
+  width: 100%;
+}
+
+.jdp-wrap input {
+  direction: ltr;
+  text-align: left;
+  unicode-bidi: plaintext;
+}
+
+.jdp-dark .jdp-wrap input,
+.jdp-dark input {
+  color: rgba(255, 255, 255, 0.88) !important;
+  background-color: #23252b !important;
+}
+
+.jdp-ultra .jdp-wrap input,
+.jdp-ultra input {
+  color: rgba(255, 255, 255, 0.88) !important;
+  background-color: #101013 !important;
+}
+
+.jdp-dark input::placeholder,
+.jdp-ultra input::placeholder {
+  color: rgba(255, 255, 255, 0.30) !important;
+}
+
+.jdp-disabled {
+  pointer-events: none;
+  opacity: 0.6;
+}

+ 98 - 0
frontend/src/components/DateTimePicker.tsx

@@ -0,0 +1,98 @@
+import { useMemo } from 'react';
+import { DatePicker } from 'antd';
+import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+import { PersianDateTimePicker } from 'persian-calendar-suite';
+
+import { useDatepicker } from '@/hooks/useDatepicker';
+import { useTheme } from '@/hooks/useTheme';
+import './DateTimePicker.css';
+
+interface DateTimePickerProps {
+  value: Dayjs | null;
+  onChange: (next: Dayjs | null) => void;
+  showTime?: boolean;
+  format?: string;
+  placeholder?: string;
+  disabled?: boolean;
+}
+
+const LIGHT_THEME = {
+  primaryColor: '#1677ff',
+  backgroundColor: '#ffffff',
+  borderColor: '#d9d9d9',
+  hoverColor: 'rgba(22, 119, 255, 0.10)',
+  selectedTextColor: '#ffffff',
+  textColor: 'rgba(0, 0, 0, 0.88)',
+};
+
+const DARK_THEME = {
+  primaryColor: '#1677ff',
+  backgroundColor: '#23252b',
+  borderColor: 'rgba(255, 255, 255, 0.12)',
+  hoverColor: 'rgba(22, 119, 255, 0.18)',
+  selectedTextColor: '#ffffff',
+  textColor: 'rgba(255, 255, 255, 0.88)',
+};
+
+const ULTRA_DARK_THEME = {
+  primaryColor: '#1677ff',
+  backgroundColor: '#101013',
+  borderColor: 'rgba(255, 255, 255, 0.08)',
+  hoverColor: 'rgba(22, 119, 255, 0.16)',
+  selectedTextColor: '#ffffff',
+  textColor: 'rgba(255, 255, 255, 0.88)',
+};
+
+export default function DateTimePicker({
+  value,
+  onChange,
+  showTime = true,
+  format = 'YYYY-MM-DD HH:mm:ss',
+  placeholder = '',
+  disabled = false,
+}: DateTimePickerProps) {
+  const { datepicker } = useDatepicker();
+  const { isDark, isUltra } = useTheme();
+
+  const persianTheme = useMemo(() => {
+    if (isUltra) return ULTRA_DARK_THEME;
+    if (isDark) return DARK_THEME;
+    return LIGHT_THEME;
+  }, [isDark, isUltra]);
+
+  if (datepicker === 'jalalian') {
+    return (
+      <div className={`jdp-wrap${isDark ? ' jdp-dark' : ''}${isUltra ? ' jdp-ultra' : ''}${disabled ? ' jdp-disabled' : ''}`}>
+        <PersianDateTimePicker
+          value={value ? value.valueOf() : null}
+          onChange={(next: number | string | null) => {
+            if (next == null || next === '') {
+              onChange(null);
+              return;
+            }
+            const ms = typeof next === 'number' ? next : Number(next);
+            if (Number.isFinite(ms)) onChange(dayjs(ms));
+          }}
+          showTime={showTime}
+          outputFormat="timestamp"
+          persianNumbers
+          rtlCalendar
+          theme={persianTheme}
+        />
+      </div>
+    );
+  }
+
+  return (
+    <DatePicker
+      value={value}
+      onChange={(next) => onChange(next || null)}
+      showTime={showTime ? { format: 'HH:mm:ss' } : false}
+      format={format}
+      placeholder={placeholder}
+      disabled={disabled}
+      style={{ width: '100%' }}
+    />
+  );
+}

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

@@ -1,366 +0,0 @@
-<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: #252526;
-  border-color: #3c3c3c;
-  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 #3c3c3c !important;
-  border-right: none !important;
-  border-radius: 6px 0 0 6px !important;
-  color: rgba(255, 255, 255, 0.75) !important;
-}
-
-body.dark .vpd-wrapper .vpd-content {
-  background: #2d2d30;
-  color: rgba(255, 255, 255, 0.88);
-  box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
-    0 3px 6px -4px rgba(0, 0, 0, 0.48),
-    0 9px 28px 8px rgba(0, 0, 0, 0.2);
-}
-
-body.dark .vpd-wrapper .vpd-body {
-  background: #2d2d30;
-  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: #2d2d30;
-  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>

+ 738 - 0
frontend/src/components/FinalMaskForm.tsx

@@ -0,0 +1,738 @@
+import { useMemo } from 'react';
+import { Button, Divider, Form, Input, InputNumber, Select, Switch } from 'antd';
+import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+
+import { RandomUtil } from '@/utils';
+import { Protocols } from '@/models/outbound.js';
+
+interface StreamShape {
+  network?: string;
+  kcp?: { mtu?: number };
+  finalmask: {
+    tcp?: MaskRow[];
+    udp?: MaskRow[];
+    enableQuicParams?: boolean;
+    quicParams?: QuicParams;
+  };
+  addTcpMask: (type?: string) => void;
+  delTcpMask: (index: number) => void;
+  addUdpMask: (type?: string) => void;
+  delUdpMask: (index: number) => void;
+}
+
+interface MaskRow {
+  type: string;
+  settings: Record<string, unknown>;
+  _getDefaultSettings: (type: string, settings: Record<string, unknown>) => Record<string, unknown>;
+}
+
+interface ItemRow {
+  type: string;
+  packet: string | unknown[];
+  delay?: number | string;
+  rand?: number | string;
+  randRange?: string;
+}
+
+interface QuicParams {
+  congestion: string;
+  debug?: boolean;
+  brutalUp?: number | string;
+  brutalDown?: number | string;
+  hasUdpHop?: boolean;
+  udpHop?: { ports: string; interval: string | number };
+  maxIdleTimeout?: number;
+  keepAlivePeriod?: number;
+  disablePathMTUDiscovery?: boolean;
+  maxIncomingStreams?: number;
+  initStreamReceiveWindow?: number;
+  maxStreamReceiveWindow?: number;
+  initConnectionReceiveWindow?: number;
+  maxConnectionReceiveWindow?: number;
+}
+
+interface FinalMaskFormProps {
+  stream: StreamShape;
+  protocol: string;
+  onChange: () => void;
+}
+
+function changeMaskType(mask: MaskRow, type: string) {
+  mask.type = type;
+  mask.settings = mask._getDefaultSettings(type, {});
+}
+
+function changeItemType(item: ItemRow, type: string) {
+  item.type = type;
+  if (type === 'base64') item.packet = RandomUtil.randomBase64();
+  else if (type === 'array') {
+    item.rand = 0;
+    item.packet = [];
+  } else item.packet = '';
+}
+
+function newClientServerItem(): ItemRow {
+  return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
+}
+
+function newUdpClientServerItem(): ItemRow {
+  return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
+}
+
+function newNoiseItem(): ItemRow {
+  return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
+}
+
+export default function FinalMaskForm({ stream, protocol, onChange }: FinalMaskFormProps) {
+  const isHysteria = protocol === Protocols.Hysteria || protocol === 'hysteria';
+  const network = stream?.network || '';
+
+  const showTcp = useMemo(
+    () => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network),
+    [network],
+  );
+  const showUdp = isHysteria || network === 'kcp';
+  const showQuic = isHysteria || network === 'xhttp';
+
+  function notify() {
+    onChange();
+  }
+
+  function changeUdpMaskType(mask: MaskRow, type: string) {
+    changeMaskType(mask, type);
+    if (network === 'kcp' && stream.kcp) {
+      stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
+    }
+    notify();
+  }
+
+  function addUdpMaskWithDefault() {
+    const def = isHysteria ? 'salamander' : 'mkcp-aes128gcm';
+    stream.addUdpMask(def);
+    notify();
+  }
+
+  const tcpMasks = stream.finalmask.tcp || [];
+  const udpMasks = stream.finalmask.udp || [];
+
+  if (!showTcp && !showUdp && !showQuic) return null;
+
+  return (
+    <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
+      {showTcp && (
+        <>
+          <Form.Item label="TCP Masks">
+            <Button
+              type="primary"
+              size="small"
+              icon={<PlusOutlined />}
+              onClick={() => {
+                stream.addTcpMask('fragment');
+                notify();
+              }}
+            />
+          </Form.Item>
+
+          {tcpMasks.map((mask, mIdx) => (
+            <div key={`tcp-${mIdx}`}>
+              <Divider style={{ margin: 0 }}>
+                TCP Mask {mIdx + 1}
+                <DeleteOutlined
+                  style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
+                  onClick={() => {
+                    stream.delTcpMask(mIdx);
+                    notify();
+                  }}
+                />
+              </Divider>
+
+              <Form.Item label="Type">
+                <Select
+                  value={mask.type}
+                  onChange={(v) => {
+                    changeMaskType(mask, v);
+                    notify();
+                  }}
+                  options={[
+                    { value: 'fragment', label: 'Fragment' },
+                    { value: 'header-custom', label: 'Header Custom' },
+                    { value: 'sudoku', label: 'Sudoku' },
+                  ]}
+                />
+              </Form.Item>
+
+              {mask.type === 'fragment' && (
+                <>
+                  <Form.Item label="Packets">
+                    <Select
+                      value={mask.settings.packets as string}
+                      onChange={(v) => {
+                        (mask.settings as Record<string, unknown>).packets = v;
+                        notify();
+                      }}
+                      options={[
+                        { value: 'tlshello', label: 'tlshello' },
+                        { value: '1-3', label: '1-3' },
+                        { value: '1-5', label: '1-5' },
+                      ]}
+                    />
+                  </Form.Item>
+                  {(['length', 'delay', 'maxSplit'] as const).map((field) => (
+                    <Form.Item key={field} label={field === 'maxSplit' ? 'Max Split' : field.charAt(0).toUpperCase() + field.slice(1)}>
+                      <Input
+                        value={(mask.settings[field] as string) || ''}
+                        onChange={(e) => {
+                          (mask.settings as Record<string, unknown>)[field] = e.target.value;
+                          notify();
+                        }}
+                      />
+                    </Form.Item>
+                  ))}
+                </>
+              )}
+
+              {mask.type === 'sudoku' && (
+                <>
+                  {(['password', 'ascii', 'customTable', 'customTables'] as const).map((field) => (
+                    <Form.Item key={field} label={field === 'customTable' ? 'Custom Table' : field === 'customTables' ? 'Custom Tables' : field.charAt(0).toUpperCase() + field.slice(1)}>
+                      <Input
+                        value={(mask.settings[field] as string) || ''}
+                        onChange={(e) => {
+                          (mask.settings as Record<string, unknown>)[field] = e.target.value;
+                          notify();
+                        }}
+                      />
+                    </Form.Item>
+                  ))}
+                  {(['paddingMin', 'paddingMax'] as const).map((field) => (
+                    <Form.Item key={field} label={field === 'paddingMin' ? 'Padding Min' : 'Padding Max'}>
+                      <InputNumber
+                        value={(mask.settings[field] as number) || 0}
+                        min={0}
+                        onChange={(v) => {
+                          (mask.settings as Record<string, unknown>)[field] = Number(v) || 0;
+                          notify();
+                        }}
+                      />
+                    </Form.Item>
+                  ))}
+                </>
+              )}
+
+              {mask.type === 'header-custom' && (
+                <HeaderCustomGroups mask={mask} kind="tcp" onChange={notify} />
+              )}
+            </div>
+          ))}
+        </>
+      )}
+
+      {showUdp && (
+        <>
+          <Form.Item label="UDP Masks">
+            <Button type="primary" size="small" icon={<PlusOutlined />} onClick={addUdpMaskWithDefault} />
+          </Form.Item>
+
+          {udpMasks.map((mask, mIdx) => (
+            <div key={`udp-${mIdx}`}>
+              <Divider style={{ margin: 0 }}>
+                UDP Mask {mIdx + 1}
+                <DeleteOutlined
+                  style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
+                  onClick={() => {
+                    stream.delUdpMask(mIdx);
+                    notify();
+                  }}
+                />
+              </Divider>
+
+              <Form.Item label="Type">
+                <Select
+                  value={mask.type}
+                  onChange={(v) => changeUdpMaskType(mask, v)}
+                  options={
+                    isHysteria
+                      ? [{ value: 'salamander', label: 'Salamander (Hysteria2)' }]
+                      : [
+                          { value: 'mkcp-aes128gcm', label: 'mKCP AES-128-GCM' },
+                          { value: 'header-dns', label: 'Header DNS' },
+                          { value: 'header-dtls', label: 'Header DTLS 1.2' },
+                          { value: 'header-srtp', label: 'Header SRTP' },
+                          { value: 'header-utp', label: 'Header uTP' },
+                          { value: 'header-wechat', label: 'Header WeChat Video' },
+                          { value: 'header-wireguard', label: 'Header WireGuard' },
+                          { value: 'mkcp-original', label: 'mKCP Original' },
+                          { value: 'xdns', label: 'xDNS' },
+                          { value: 'xicmp', label: 'xICMP' },
+                          { value: 'header-custom', label: 'Header Custom' },
+                          { value: 'noise', label: 'Noise' },
+                        ]
+                  }
+                />
+              </Form.Item>
+
+              {['mkcp-aes128gcm', 'salamander'].includes(mask.type) && (
+                <Form.Item label="Password">
+                  <Input
+                    value={(mask.settings.password as string) || ''}
+                    placeholder="Obfuscation password"
+                    onChange={(e) => {
+                      (mask.settings as Record<string, unknown>).password = e.target.value;
+                      notify();
+                    }}
+                  />
+                </Form.Item>
+              )}
+
+              {mask.type === 'header-dns' && (
+                <Form.Item label="Domain">
+                  <Input
+                    value={(mask.settings.domain as string) || ''}
+                    placeholder="e.g., www.example.com"
+                    onChange={(e) => {
+                      (mask.settings as Record<string, unknown>).domain = e.target.value;
+                      notify();
+                    }}
+                  />
+                </Form.Item>
+              )}
+
+              {mask.type === 'xdns' && (
+                <Form.Item label="Domains">
+                  <Select
+                    mode="tags"
+                    value={(mask.settings.domains as string[]) || []}
+                    style={{ width: '100%' }}
+                    tokenSeparators={[',']}
+                    placeholder="e.g., www.example.com"
+                    onChange={(v) => {
+                      (mask.settings as Record<string, unknown>).domains = v;
+                      notify();
+                    }}
+                  />
+                </Form.Item>
+              )}
+
+              {mask.type === 'noise' && (
+                <NoiseItems mask={mask} onChange={notify} />
+              )}
+
+              {mask.type === 'header-custom' && (
+                <UdpHeaderCustom mask={mask} onChange={notify} />
+              )}
+
+              {mask.type === 'xicmp' && (
+                <>
+                  <Form.Item label="IP">
+                    <Input
+                      value={(mask.settings.ip as string) || ''}
+                      placeholder="0.0.0.0"
+                      onChange={(e) => {
+                        (mask.settings as Record<string, unknown>).ip = e.target.value;
+                        notify();
+                      }}
+                    />
+                  </Form.Item>
+                  <Form.Item label="ID">
+                    <InputNumber
+                      value={(mask.settings.id as number) || 0}
+                      min={0}
+                      onChange={(v) => {
+                        (mask.settings as Record<string, unknown>).id = Number(v) || 0;
+                        notify();
+                      }}
+                    />
+                  </Form.Item>
+                </>
+              )}
+            </div>
+          ))}
+        </>
+      )}
+
+      {showQuic && (
+        <>
+          <Form.Item label="QUIC Params">
+            <Switch
+              checked={!!stream.finalmask.enableQuicParams}
+              onChange={(v) => {
+                stream.finalmask.enableQuicParams = v;
+                notify();
+              }}
+            />
+          </Form.Item>
+          {stream.finalmask.enableQuicParams && stream.finalmask.quicParams && (
+            <QuicParamsForm params={stream.finalmask.quicParams} onChange={notify} />
+          )}
+        </>
+      )}
+    </Form>
+  );
+}
+
+function HeaderCustomGroups({
+  mask,
+  kind: _kind,
+  onChange,
+}: {
+  mask: MaskRow;
+  kind: 'tcp';
+  onChange: () => void;
+}) {
+  const settings = mask.settings as { clients?: ItemRow[][]; servers?: ItemRow[][] };
+  if (!settings.clients) settings.clients = [];
+  if (!settings.servers) settings.servers = [];
+
+  return (
+    <>
+      {(['clients', 'servers'] as const).map((groupKey) => (
+        <div key={groupKey}>
+          <Form.Item label={groupKey === 'clients' ? 'Clients' : 'Servers'}>
+            <Button
+              type="primary"
+              size="small"
+              icon={<PlusOutlined />}
+              onClick={() => {
+                (settings[groupKey] as ItemRow[][]).push([newClientServerItem()]);
+                onChange();
+              }}
+            />
+          </Form.Item>
+          {(settings[groupKey] as ItemRow[][]).map((group, gi) => (
+            <div key={`${groupKey}-${gi}`}>
+              <Divider style={{ margin: 0 }}>
+                {groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
+                <DeleteOutlined
+                  style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
+                  onClick={() => {
+                    (settings[groupKey] as ItemRow[][]).splice(gi, 1);
+                    onChange();
+                  }}
+                />
+              </Divider>
+              {group.map((item, _ii) => (
+                <ItemEditor key={_ii} item={item} onChange={onChange} delayAsNumber />
+              ))}
+            </div>
+          ))}
+        </div>
+      ))}
+    </>
+  );
+}
+
+function UdpHeaderCustom({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
+  const settings = mask.settings as { client?: ItemRow[]; server?: ItemRow[] };
+  if (!settings.client) settings.client = [];
+  if (!settings.server) settings.server = [];
+  return (
+    <>
+      {(['client', 'server'] as const).map((groupKey) => (
+        <div key={groupKey}>
+          <Form.Item label={groupKey === 'client' ? 'Client' : 'Server'}>
+            <Button
+              type="primary"
+              size="small"
+              icon={<PlusOutlined />}
+              onClick={() => {
+                (settings[groupKey] as ItemRow[]).push(newUdpClientServerItem());
+                onChange();
+              }}
+            />
+          </Form.Item>
+          {(settings[groupKey] as ItemRow[]).map((item, ci) => (
+            <div key={ci}>
+              <Divider style={{ margin: 0 }}>
+                {groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
+                <DeleteOutlined
+                  style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
+                  onClick={() => {
+                    (settings[groupKey] as ItemRow[]).splice(ci, 1);
+                    onChange();
+                  }}
+                />
+              </Divider>
+              <ItemEditor item={item} onChange={onChange} />
+            </div>
+          ))}
+        </div>
+      ))}
+    </>
+  );
+}
+
+function NoiseItems({ mask, onChange }: { mask: MaskRow; onChange: () => void }) {
+  const settings = mask.settings as { reset?: number; noise?: ItemRow[] };
+  if (!settings.noise) settings.noise = [];
+
+  return (
+    <>
+      <Form.Item label="Reset">
+        <InputNumber
+          value={settings.reset || 0}
+          min={0}
+          onChange={(v) => {
+            settings.reset = Number(v) || 0;
+            onChange();
+          }}
+        />
+      </Form.Item>
+      <Form.Item label="Noise">
+        <Button
+          type="primary"
+          size="small"
+          icon={<PlusOutlined />}
+          onClick={() => {
+            (settings.noise as ItemRow[]).push(newNoiseItem());
+            onChange();
+          }}
+        />
+      </Form.Item>
+      {(settings.noise as ItemRow[]).map((n, ni) => (
+        <div key={ni}>
+          <Divider style={{ margin: 0 }}>
+            Noise {ni + 1}
+            <DeleteOutlined
+              style={{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: 8 }}
+              onClick={() => {
+                (settings.noise as ItemRow[]).splice(ni, 1);
+                onChange();
+              }}
+            />
+          </Divider>
+          <ItemEditor item={n} onChange={onChange} delayAsString />
+        </div>
+      ))}
+    </>
+  );
+}
+
+function ItemEditor({
+  item,
+  onChange,
+  delayAsNumber,
+  delayAsString,
+}: {
+  item: ItemRow;
+  onChange: () => void;
+  delayAsNumber?: boolean;
+  delayAsString?: boolean;
+}) {
+  return (
+    <>
+      <Form.Item label="Type">
+        <Select
+          value={item.type}
+          onChange={(v) => {
+            changeItemType(item, v);
+            onChange();
+          }}
+          options={[
+            { value: 'array', label: 'Array' },
+            { value: 'str', label: 'String' },
+            { value: 'hex', label: 'Hex' },
+            { value: 'base64', label: 'Base64' },
+          ]}
+        />
+      </Form.Item>
+      {delayAsNumber && (
+        <Form.Item label="Delay (ms)">
+          <InputNumber
+            value={typeof item.delay === 'number' ? item.delay : 0}
+            min={0}
+            onChange={(v) => {
+              item.delay = Number(v) || 0;
+              onChange();
+            }}
+          />
+        </Form.Item>
+      )}
+      {item.type === 'array' ? (
+        <>
+          <Form.Item label="Rand">
+            {delayAsString ? (
+              <Input
+                value={String(item.rand ?? '')}
+                onChange={(e) => {
+                  item.rand = e.target.value;
+                  onChange();
+                }}
+                placeholder="0 or 1-8192"
+              />
+            ) : (
+              <InputNumber
+                value={typeof item.rand === 'number' ? item.rand : 0}
+                min={0}
+                onChange={(v) => {
+                  item.rand = Number(v) || 0;
+                  onChange();
+                }}
+              />
+            )}
+          </Form.Item>
+          <Form.Item label="Rand Range">
+            <Input
+              value={item.randRange || ''}
+              placeholder="0-255"
+              onChange={(e) => {
+                item.randRange = e.target.value;
+                onChange();
+              }}
+            />
+          </Form.Item>
+        </>
+      ) : (
+        <Form.Item label="Packet">
+          {item.type === 'base64' ? (
+            <Input.Group compact>
+              <Input
+                value={String(item.packet ?? '')}
+                placeholder="binary data"
+                style={{ width: 'calc(100% - 32px)' }}
+                onChange={(e) => {
+                  item.packet = e.target.value;
+                  onChange();
+                }}
+              />
+              <Button
+                icon={<ReloadOutlined />}
+                onClick={() => {
+                  item.packet = RandomUtil.randomBase64();
+                  onChange();
+                }}
+              />
+            </Input.Group>
+          ) : (
+            <Input
+              value={String(item.packet ?? '')}
+              placeholder="binary data"
+              onChange={(e) => {
+                item.packet = e.target.value;
+                onChange();
+              }}
+            />
+          )}
+        </Form.Item>
+      )}
+      {delayAsString && (
+        <Form.Item label="Delay">
+          <Input
+            value={typeof item.delay === 'string' ? item.delay : ''}
+            placeholder="10-20"
+            onChange={(e) => {
+              item.delay = e.target.value;
+              onChange();
+            }}
+          />
+        </Form.Item>
+      )}
+    </>
+  );
+}
+
+function QuicParamsForm({ params, onChange }: { params: QuicParams; onChange: () => void }) {
+  function update<K extends keyof QuicParams>(key: K, value: QuicParams[K]) {
+    params[key] = value;
+    onChange();
+  }
+  return (
+    <>
+      <Form.Item label="Congestion">
+        <Select
+          value={params.congestion}
+          onChange={(v) => update('congestion', v)}
+          options={[
+            { value: 'reno', label: 'Reno' },
+            { value: 'bbr', label: 'BBR' },
+            { value: 'brutal', label: 'Brutal' },
+            { value: 'force-brutal', label: 'Force Brutal' },
+          ]}
+        />
+      </Form.Item>
+      <Form.Item label="Debug">
+        <Switch checked={!!params.debug} onChange={(v) => update('debug', v)} />
+      </Form.Item>
+      {['brutal', 'force-brutal'].includes(params.congestion) && (
+        <>
+          <Form.Item label="Brutal Up">
+            <Input
+              value={String(params.brutalUp ?? '')}
+              placeholder="65537"
+              onChange={(e) => update('brutalUp', e.target.value)}
+            />
+          </Form.Item>
+          <Form.Item label="Brutal Down">
+            <Input
+              value={String(params.brutalDown ?? '')}
+              placeholder="65537"
+              onChange={(e) => update('brutalDown', e.target.value)}
+            />
+          </Form.Item>
+        </>
+      )}
+      <Form.Item label="UDP Hop">
+        <Switch checked={!!params.hasUdpHop} onChange={(v) => update('hasUdpHop', v)} />
+      </Form.Item>
+      {params.hasUdpHop && params.udpHop && (
+        <>
+          <Form.Item label="Hop Ports">
+            <Input
+              value={params.udpHop.ports || ''}
+              placeholder="e.g. 20000-50000"
+              onChange={(e) => {
+                params.udpHop!.ports = e.target.value;
+                onChange();
+              }}
+            />
+          </Form.Item>
+          <Form.Item label="Hop Interval (s)">
+            <InputNumber
+              value={Number(params.udpHop.interval) || 5}
+              min={5}
+              onChange={(v) => {
+                params.udpHop!.interval = Number(v) || 5;
+                onChange();
+              }}
+            />
+          </Form.Item>
+        </>
+      )}
+      {(
+        [
+          ['maxIdleTimeout', 'Max Idle Timeout (s)', 4, 120],
+          ['keepAlivePeriod', 'Keep Alive Period (s)', 2, 60],
+        ] as const
+      ).map(([key, label, min, max]) => (
+        <Form.Item key={key} label={label}>
+          <InputNumber
+            value={params[key] as number}
+            min={min}
+            max={max}
+            onChange={(v) => update(key, Number(v) || min)}
+          />
+        </Form.Item>
+      ))}
+      <Form.Item label="Disable Path MTU Dis">
+        <Switch checked={!!params.disablePathMTUDiscovery} onChange={(v) => update('disablePathMTUDiscovery', v)} />
+      </Form.Item>
+      {(
+        [
+          ['maxIncomingStreams', 'Max Incoming Streams', 8, '1024 = default'],
+          ['initStreamReceiveWindow', 'Init Stream Window', 16384, '8388608 = default'],
+          ['maxStreamReceiveWindow', 'Max Stream Window', 16384, '8388608 = default'],
+          ['initConnectionReceiveWindow', 'Init Conn Window', 16384, '20971520 = default'],
+          ['maxConnectionReceiveWindow', 'Max Conn Window', 16384, '20971520 = default'],
+        ] as const
+      ).map(([key, label, min, placeholder]) => (
+        <Form.Item key={key} label={label}>
+          <InputNumber
+            value={params[key] as number}
+            min={min}
+            placeholder={placeholder}
+            onChange={(v) => update(key, Number(v) || 0)}
+          />
+        </Form.Item>
+      ))}
+    </>
+  );
+}

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

@@ -1,510 +0,0 @@
-<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>

+ 19 - 0
frontend/src/components/InfinityIcon.tsx

@@ -0,0 +1,19 @@
+interface InfinityIconProps {
+  width?: number | string;
+  height?: number | string;
+}
+
+export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
+  return (
+    <svg
+      width={width}
+      height={height}
+      viewBox="0 0 640 512"
+      fill="currentColor"
+      aria-hidden="true"
+      style={{ verticalAlign: '-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>
+  );
+}

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

@@ -1,18 +0,0 @@
-<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>

+ 40 - 0
frontend/src/components/InputAddon.css

@@ -0,0 +1,40 @@
+.input-addon {
+  display: inline-flex;
+  align-items: center;
+  padding: 0 11px;
+  height: 32px;
+  font-size: 14px;
+  line-height: 30px;
+  background-color: rgba(0, 0, 0, 0.02);
+  border: 1px solid #d9d9d9;
+  border-radius: 6px;
+  position: relative;
+  z-index: 1;
+  color: rgba(0, 0, 0, 0.88);
+  white-space: nowrap;
+}
+
+body.dark .input-addon,
+html[data-theme='ultra-dark'] .input-addon {
+  background-color: rgba(255, 255, 255, 0.04);
+  border-color: #424242;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.ant-space-compact > .input-addon:not(:first-child) {
+  margin-inline-start: -1px;
+}
+
+.ant-space-compact > .input-addon:first-child {
+  border-start-end-radius: 0;
+  border-end-end-radius: 0;
+}
+
+.ant-space-compact > .input-addon:last-child {
+  border-start-start-radius: 0;
+  border-end-start-radius: 0;
+}
+
+.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
+  border-radius: 0;
+}

+ 21 - 0
frontend/src/components/InputAddon.tsx

@@ -0,0 +1,21 @@
+import type { CSSProperties, ReactNode } from 'react';
+import './InputAddon.css';
+
+interface InputAddonProps {
+  children: ReactNode;
+  className?: string;
+  style?: CSSProperties;
+  onClick?: () => void;
+}
+
+export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
+  return (
+    <span
+      className={`input-addon ${className}`.trim()}
+      style={style}
+      onClick={onClick}
+    >
+      {children}
+    </span>
+  );
+}

+ 26 - 0
frontend/src/components/JsonEditor.css

@@ -0,0 +1,26 @@
+.json-editor-host {
+  border: 1px solid var(--ant-color-border, #d9d9d9);
+  border-radius: 6px;
+  overflow: hidden;
+  background: var(--ant-color-bg-container, #fff);
+}
+
+.json-editor-host .cm-editor,
+.json-editor-host .cm-editor.cm-focused {
+  outline: none;
+}
+
+.json-editor-host:focus-within {
+  border-color: var(--ant-color-primary, #1677ff);
+  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
+}
+
+body.dark .json-editor-host {
+  border-color: #3a3a3c;
+  background: #1e1e1e;
+}
+
+html[data-theme="ultra-dark"] .json-editor-host {
+  border-color: #1f1f1f;
+  background: #0a0a0a;
+}

+ 179 - 0
frontend/src/components/JsonEditor.tsx

@@ -0,0 +1,179 @@
+import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
+import { EditorView, basicSetup } from 'codemirror';
+import { EditorState, Compartment } from '@codemirror/state';
+import { json, jsonParseLinter } from '@codemirror/lang-json';
+import { lintGutter, linter } from '@codemirror/lint';
+import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
+import { syntaxHighlighting } from '@codemirror/language';
+import { keymap } from '@codemirror/view';
+import { indentWithTab } from '@codemirror/commands';
+
+import { useTheme } from '@/hooks/useTheme';
+import './JsonEditor.css';
+
+export interface JsonEditorProps {
+  value: string;
+  onChange?: (next: string) => void;
+  minHeight?: string;
+  maxHeight?: string;
+  readOnly?: boolean;
+}
+
+export interface JsonEditorHandle {
+  focus: () => void;
+}
+
+interface DarkPalette {
+  bg: string;
+  panelBg: string;
+  activeBg: string;
+  border: string;
+  selection: string;
+}
+
+function buildDarkTheme({ bg, panelBg, activeBg, border, selection }: DarkPalette) {
+  return EditorView.theme(
+    {
+      '&': { color: '#dcdcdc', backgroundColor: bg },
+      '.cm-content': { caretColor: '#dcdcdc' },
+      '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
+      '.cm-gutters': {
+        backgroundColor: bg,
+        borderRight: `1px solid ${border}`,
+        color: '#6a6a6a',
+      },
+      '.cm-activeLine': { backgroundColor: activeBg },
+      '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
+      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
+        { backgroundColor: selection },
+      '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
+      '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
+      '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
+      '.cm-tooltip': {
+        backgroundColor: panelBg,
+        border: `1px solid ${border}`,
+        color: '#dcdcdc',
+      },
+    },
+    { dark: true },
+  );
+}
+
+const darkTheme = buildDarkTheme({
+  bg: '#1e1e1e',
+  panelBg: '#2d2d30',
+  activeBg: '#252526',
+  border: '#3a3a3c',
+  selection: '#3a3a3c',
+});
+
+const ultraDarkTheme = buildDarkTheme({
+  bg: '#0a0a0a',
+  panelBg: '#141414',
+  activeBg: '#141414',
+  border: '#1f1f1f',
+  selection: '#2a2a2a',
+});
+
+function themeExtension(isDark: boolean, isUltra: boolean) {
+  if (!isDark) return [];
+  const chrome = isUltra ? ultraDarkTheme : darkTheme;
+  return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
+}
+
+const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEditor(
+  { value, onChange, minHeight = '320px', maxHeight = '600px', readOnly = false },
+  ref,
+) {
+  const hostRef = useRef<HTMLDivElement | null>(null);
+  const viewRef = useRef<EditorView | null>(null);
+  const themeCompartmentRef = useRef<Compartment>(new Compartment());
+  const readonlyCompartmentRef = useRef<Compartment>(new Compartment());
+  const onChangeRef = useRef(onChange);
+  const valueRef = useRef(value);
+  const { isDark, isUltra } = useTheme();
+
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  }, [onChange]);
+
+  useImperativeHandle(ref, () => ({
+    focus: () => viewRef.current?.focus(),
+  }));
+
+  useEffect(() => {
+    if (!hostRef.current) return;
+
+    const updateListener = EditorView.updateListener.of((u) => {
+      if (!u.docChanged) return;
+      const next = u.state.doc.toString();
+      if (next === valueRef.current) return;
+      valueRef.current = next;
+      onChangeRef.current?.(next);
+    });
+
+    const view = new EditorView({
+      parent: hostRef.current,
+      state: EditorState.create({
+        doc: value,
+        extensions: [
+          basicSetup,
+          keymap.of([indentWithTab]),
+          json(),
+          linter(jsonParseLinter()),
+          lintGutter(),
+          EditorView.lineWrapping,
+          updateListener,
+          themeCompartmentRef.current.of(themeExtension(isDark, isUltra)),
+          readonlyCompartmentRef.current.of(EditorState.readOnly.of(readOnly)),
+          EditorView.theme({
+            '&': { height: '100%' },
+            '.cm-scroller': {
+              fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+              fontSize: '12px',
+              minHeight,
+              maxHeight,
+            },
+          }),
+        ],
+      }),
+    });
+
+    viewRef.current = view;
+
+    return () => {
+      view.destroy();
+      viewRef.current = null;
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const view = viewRef.current;
+    if (!view) return;
+    const current = view.state.doc.toString();
+    if (value === current) return;
+    valueRef.current = value;
+    view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
+  }, [value]);
+
+  useEffect(() => {
+    const view = viewRef.current;
+    if (!view) return;
+    view.dispatch({
+      effects: themeCompartmentRef.current.reconfigure(themeExtension(isDark, isUltra)),
+    });
+  }, [isDark, isUltra]);
+
+  useEffect(() => {
+    const view = viewRef.current;
+    if (!view) return;
+    view.dispatch({
+      effects: readonlyCompartmentRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
+    });
+  }, [readOnly]);
+
+  return <div ref={hostRef} className="json-editor-host" />;
+});
+
+export default JsonEditor;

+ 0 - 185
frontend/src/components/JsonEditor.vue

@@ -1,185 +0,0 @@
-<script setup>
-import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
-import { EditorView, basicSetup } from 'codemirror';
-import { EditorState, Compartment } from '@codemirror/state';
-import { json, jsonParseLinter } from '@codemirror/lang-json';
-import { lintGutter, linter } from '@codemirror/lint';
-import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
-import { syntaxHighlighting } from '@codemirror/language';
-import { keymap } from '@codemirror/view';
-import { indentWithTab } from '@codemirror/commands';
-
-import { theme as themeState } from '@/composables/useTheme.js';
-
-const props = defineProps({
-  value: { type: String, default: '' },
-  minHeight: { type: String, default: '320px' },
-  maxHeight: { type: String, default: '600px' },
-  readonly: { type: Boolean, default: false },
-});
-
-const emit = defineEmits(['update:value', 'change']);
-
-const host = ref(null);
-let view = null;
-const themeCompartment = new Compartment();
-const readonlyCompartment = new Compartment();
-
-function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
-  return EditorView.theme(
-    {
-      '&': { color: '#dcdcdc', backgroundColor: bg },
-      '.cm-content': { caretColor: '#dcdcdc' },
-      '.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
-      '.cm-gutters': {
-        backgroundColor: bg,
-        borderRight: `1px solid ${border}`,
-        color: '#6a6a6a',
-      },
-      '.cm-activeLine': { backgroundColor: activeBg },
-      '.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
-      '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
-        { backgroundColor: selection },
-      '.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
-      '.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
-      '.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
-      '.cm-tooltip': {
-        backgroundColor: panelBg,
-        border: `1px solid ${border}`,
-        color: '#dcdcdc',
-      },
-    },
-    { dark: true },
-  );
-}
-
-const darkTheme = buildDarkTheme({
-  bg: '#1e1e1e',
-  panelBg: '#2d2d30',
-  activeBg: '#252526',
-  border: '#3a3a3c',
-  selection: '#3a3a3c',
-});
-
-const ultraDarkTheme = buildDarkTheme({
-  bg: '#0a0a0a',
-  panelBg: '#141414',
-  activeBg: '#141414',
-  border: '#1f1f1f',
-  selection: '#2a2a2a',
-});
-
-function themeExtension() {
-  if (!themeState.isDark) return [];
-  const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
-  return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
-}
-
-function readonlyExtension() {
-  return EditorState.readOnly.of(props.readonly);
-}
-
-onMounted(() => {
-  const updateListener = EditorView.updateListener.of((u) => {
-    if (!u.docChanged) return;
-    const next = u.state.doc.toString();
-    if (next === props.value) return;
-    emit('update:value', next);
-    emit('change', next);
-  });
-
-  view = new EditorView({
-    parent: host.value,
-    state: EditorState.create({
-      doc: props.value || '',
-      extensions: [
-        basicSetup,
-        keymap.of([indentWithTab]),
-        json(),
-        linter(jsonParseLinter()),
-        lintGutter(),
-        EditorView.lineWrapping,
-        updateListener,
-        themeCompartment.of(themeExtension()),
-        readonlyCompartment.of(readonlyExtension()),
-        EditorView.theme({
-          '&': { height: '100%' },
-          '.cm-scroller': {
-            fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
-            fontSize: '12px',
-            minHeight: props.minHeight,
-            maxHeight: props.maxHeight,
-          },
-        }),
-      ],
-    }),
-  });
-});
-
-watch(() => props.value, (next) => {
-  if (!view) return;
-  const current = view.state.doc.toString();
-  if (next === current) return;
-  view.dispatch({
-    changes: { from: 0, to: current.length, insert: next || '' },
-  });
-});
-
-watch(
-  [() => themeState.isDark, () => themeState.isUltra],
-  () => {
-    if (!view) return;
-    view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
-  },
-);
-
-watch(
-  () => props.readonly,
-  () => {
-    if (!view) return;
-    view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
-  },
-);
-
-onBeforeUnmount(() => {
-  view?.destroy();
-  view = null;
-});
-
-defineExpose({
-  focus: () => view?.focus(),
-});
-</script>
-
-<template>
-  <div ref="host" class="json-editor-host" />
-</template>
-
-<style scoped>
-.json-editor-host {
-  border: 1px solid var(--ant-color-border, #d9d9d9);
-  border-radius: 6px;
-  overflow: hidden;
-  background: var(--ant-color-bg-container, #fff);
-}
-
-.json-editor-host :deep(.cm-editor),
-.json-editor-host :deep(.cm-editor.cm-focused) {
-  outline: none;
-}
-
-.json-editor-host:focus-within {
-  border-color: var(--ant-color-primary, #1677ff);
-  box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
-}
-
-:global(body.dark) .json-editor-host {
-  border-color: #3a3a3c;
-  background: #1e1e1e;
-}
-
-:global(html[data-theme="ultra-dark"]) .json-editor-host {
-  border-color: #1f1f1f;
-  background: #0a0a0a;
-}
-</style>

+ 82 - 0
frontend/src/components/PromptModal.tsx

@@ -0,0 +1,82 @@
+import { useEffect, useRef, useState } from 'react';
+import { Input, Modal } from 'antd';
+import type { InputRef } from 'antd';
+
+interface PromptModalProps {
+  open: boolean;
+  onClose: () => void;
+  title: string;
+  okText?: string;
+  type?: 'input' | 'textarea';
+  initialValue?: string;
+  loading?: boolean;
+  onConfirm: (value: string) => void;
+}
+
+export default function PromptModal({
+  open,
+  onClose,
+  title,
+  okText = 'OK',
+  type = 'input',
+  initialValue = '',
+  loading = false,
+  onConfirm,
+}: PromptModalProps) {
+  const [value, setValue] = useState('');
+  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
+  const inputRef = useRef<InputRef | null>(null);
+
+  useEffect(() => {
+    if (open) {
+      setValue(initialValue);
+      setTimeout(() => {
+        if (type === 'textarea') textareaRef.current?.focus();
+        else inputRef.current?.focus();
+      }, 50);
+    }
+  }, [open, initialValue, type]);
+
+  function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
+    if (type !== 'textarea' && e.key === 'Enter') {
+      e.preventDefault();
+      onConfirm(value);
+      return;
+    }
+    if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
+      e.preventDefault();
+      onConfirm(value);
+    }
+  }
+
+  return (
+    <Modal
+      open={open}
+      title={title}
+      okText={okText}
+      cancelText="Cancel"
+      mask={{ closable: false }}
+      confirmLoading={loading}
+      onOk={() => onConfirm(value)}
+      onCancel={onClose}
+      destroyOnHidden
+    >
+      {type === 'textarea' ? (
+        <Input.TextArea
+          ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
+          value={value}
+          onChange={(e) => setValue(e.target.value)}
+          autoSize={{ minRows: 10, maxRows: 20 }}
+          onKeyDown={onKeydown}
+        />
+      ) : (
+        <Input
+          ref={inputRef}
+          value={value}
+          onChange={(e) => setValue(e.target.value)}
+          onKeyDown={onKeydown}
+        />
+      )}
+    </Modal>
+  );
+}

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

@@ -1,52 +0,0 @@
-<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>

+ 43 - 0
frontend/src/components/SettingListItem.css

@@ -0,0 +1,43 @@
+.setting-list-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-bottom: 1px solid rgba(5, 5, 5, 0.06);
+}
+
+.setting-list-item:last-child {
+  border-bottom: 0;
+}
+
+body.dark .setting-list-item,
+html[data-theme='ultra-dark'] .setting-list-item {
+  border-bottom-color: rgba(255, 255, 255, 0.08);
+}
+
+.setting-list-meta {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.setting-list-title {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.88);
+  font-weight: 500;
+}
+
+.setting-list-description {
+  font-size: 14px;
+  color: rgba(0, 0, 0, 0.45);
+  line-height: 1.5715;
+}
+
+body.dark .setting-list-title,
+html[data-theme='ultra-dark'] .setting-list-title {
+  color: rgba(255, 255, 255, 0.85);
+}
+
+body.dark .setting-list-description,
+html[data-theme='ultra-dark'] .setting-list-description {
+  color: rgba(255, 255, 255, 0.45);
+}

+ 36 - 0
frontend/src/components/SettingListItem.tsx

@@ -0,0 +1,36 @@
+import type { ReactNode } from 'react';
+import { Col, Row } from 'antd';
+import './SettingListItem.css';
+
+interface SettingListItemProps {
+  paddings?: 'small' | 'default';
+  title?: ReactNode;
+  description?: ReactNode;
+  children?: ReactNode;
+  control?: ReactNode;
+}
+
+export default function SettingListItem({
+  paddings = 'default',
+  title,
+  description,
+  children,
+  control,
+}: SettingListItemProps) {
+  const padding = paddings === 'small' ? '10px 20px' : '20px';
+  return (
+    <div className="setting-list-item" style={{ padding }}>
+      <Row gutter={[8, 16]} style={{ width: '100%' }}>
+        <Col xs={24} lg={12}>
+          <div className="setting-list-meta">
+            {title && <div className="setting-list-title">{title}</div>}
+            {description && <div className="setting-list-description">{description}</div>}
+          </div>
+        </Col>
+        <Col xs={24} lg={12}>
+          {control ?? children}
+        </Col>
+      </Row>
+    </div>
+  );
+}

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

@@ -1,35 +0,0 @@
-<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 :xs="24" :lg="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 :xs="24" :lg="12">
-        <slot name="control" />
-      </a-col>
-    </a-row>
-  </a-list-item>
-</template>

+ 44 - 0
frontend/src/components/Sparkline.css

@@ -0,0 +1,44 @@
+.sparkline-svg {
+  display: block;
+  width: 100%;
+}
+
+.sparkline-svg .cpu-grid-y-text,
+.sparkline-svg .cpu-grid-x-text {
+  fill: rgba(0, 0, 0, 0.55);
+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+  letter-spacing: 0.2px;
+}
+
+.sparkline-svg .cpu-grid-text {
+  fill: rgba(0, 0, 0, 0.88);
+}
+
+.sparkline-svg .cpu-grid-line {
+  stroke: rgba(0, 0, 0, 0.08);
+}
+
+.sparkline-svg .cpu-tooltip-text {
+  pointer-events: none;
+}
+
+.sparkline-svg .cpu-tooltip-pill {
+  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.18));
+}
+
+body.dark .sparkline-svg .cpu-grid-y-text,
+body.dark .sparkline-svg .cpu-grid-x-text {
+  fill: rgba(255, 255, 255, 0.7);
+}
+
+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.10);
+}
+
+body.dark .sparkline-svg .cpu-tooltip-pill {
+  filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.6));
+}

+ 368 - 0
frontend/src/components/Sparkline.tsx

@@ -0,0 +1,368 @@
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import type { MouseEvent } from 'react';
+import './Sparkline.css';
+
+interface SparklineProps {
+  data: number[];
+  labels?: (string | number)[];
+  vbWidth?: number;
+  height?: number;
+  stroke?: string;
+  strokeWidth?: number;
+  maxPoints?: number;
+  showGrid?: boolean;
+  gridColor?: string;
+  fillOpacity?: number;
+  showMarker?: boolean;
+  markerRadius?: number;
+  showAxes?: boolean;
+  yTickStep?: number;
+  tickCountX?: number;
+  paddingLeft?: number;
+  paddingRight?: number;
+  paddingTop?: number;
+  paddingBottom?: number;
+  showTooltip?: boolean;
+  valueMin?: number;
+  valueMax?: number | null;
+  yFormatter?: (v: number) => string;
+  tooltipFormatter?: ((v: number) => string) | null;
+}
+
+export default function Sparkline({
+  data,
+  labels = [],
+  vbWidth = 320,
+  height = 80,
+  stroke = '#008771',
+  strokeWidth = 2,
+  maxPoints = 120,
+  showGrid = true,
+  gridColor = 'rgba(0,0,0,0.08)',
+  fillOpacity = 0.22,
+  showMarker = true,
+  markerRadius = 3,
+  showAxes = false,
+  yTickStep = 25,
+  tickCountX = 4,
+  paddingLeft = 56,
+  paddingRight = 6,
+  paddingTop = 6,
+  paddingBottom = 20,
+  showTooltip = false,
+  valueMin = 0,
+  valueMax = 100,
+  yFormatter = (v: number) => `${Math.round(v)}%`,
+  tooltipFormatter = null,
+}: SparklineProps) {
+  const svgRef = useRef<SVGSVGElement | null>(null);
+  const [measuredWidth, setMeasuredWidth] = useState(0);
+  const [hoverIdx, setHoverIdx] = useState(-1);
+
+  const reactId = useId();
+  const safeId = reactId.replace(/[^a-zA-Z0-9]/g, '');
+  const gradId = `spkGrad-${safeId}`;
+  const shadowId = `spkShadow-${safeId}`;
+  const glowId = `spkGlow-${safeId}`;
+
+  useEffect(() => {
+    const el = svgRef.current;
+    if (!el) return;
+    const measure = () => {
+      const w = el.getBoundingClientRect?.().width || 0;
+      if (w > 0) setMeasuredWidth(Math.round(w));
+    };
+    measure();
+    if (typeof ResizeObserver !== 'undefined') {
+      const ro = new ResizeObserver(measure);
+      ro.observe(el);
+      return () => ro.disconnect();
+    }
+    window.addEventListener('resize', measure);
+    return () => window.removeEventListener('resize', measure);
+  }, []);
+
+  const effectiveVbWidth = measuredWidth > 0 ? measuredWidth : vbWidth;
+  const drawWidth = Math.max(1, effectiveVbWidth - paddingLeft - paddingRight);
+  const drawHeight = Math.max(1, height - paddingTop - paddingBottom);
+  const nPoints = Math.min(data.length, maxPoints);
+
+  const dataSlice = useMemo(
+    () => (nPoints === 0 ? [] : data.slice(data.length - nPoints)),
+    [data, nPoints],
+  );
+
+  const labelsSlice = useMemo(() => {
+    if (!labels?.length || nPoints === 0) return [] as (string | number)[];
+    const start = Math.max(0, labels.length - nPoints);
+    return labels.slice(start);
+  }, [labels, nPoints]);
+
+  const yDomain = useMemo(() => {
+    const min = valueMin;
+    if (valueMax != null) return { min, max: valueMax };
+    let max = min;
+    for (const v of dataSlice) {
+      const n = Number(v);
+      if (Number.isFinite(n) && n > max) max = n;
+    }
+    if (max <= min) max = min + 1;
+    return { min, max: max * 1.1 };
+  }, [dataSlice, valueMin, valueMax]);
+
+  const project = useCallback(
+    (v: number) => {
+      const { min, max } = yDomain;
+      const span = max - min;
+      if (span <= 0) return paddingTop + drawHeight;
+      const clipped = Math.max(min, Math.min(max, Number(v) || 0));
+      const ratio = (clipped - min) / span;
+      return Math.round(paddingTop + (drawHeight - ratio * drawHeight));
+    },
+    [yDomain, paddingTop, drawHeight],
+  );
+
+  const pointsArr = useMemo<[number, number][]>(() => {
+    if (nPoints === 0) return [];
+    const w = drawWidth;
+    const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
+    return dataSlice.map((v, i) => {
+      const x = Math.round(paddingLeft + i * dx);
+      return [x, project(v)];
+    });
+  }, [dataSlice, nPoints, drawWidth, paddingLeft, project]);
+
+  const pointsStr = useMemo(() => pointsArr.map((p) => `${p[0]},${p[1]}`).join(' '), [pointsArr]);
+
+  const areaPath = useMemo(() => {
+    if (pointsArr.length === 0) return '';
+    const first = pointsArr[0];
+    const last = pointsArr[pointsArr.length - 1];
+    const baseY = paddingTop + drawHeight;
+    const line = pointsStr.replace(/ /g, ' L ');
+    return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
+  }, [pointsArr, pointsStr, paddingTop, drawHeight]);
+
+  const gridLines = useMemo(() => {
+    if (!showGrid) return [];
+    const h = drawHeight;
+    const w = drawWidth;
+    return [0, 0.25, 0.5, 0.75, 1].map((r) => {
+      const y = Math.round(paddingTop + h * r);
+      return { x1: paddingLeft, y1: y, x2: paddingLeft + w, y2: y };
+    });
+  }, [showGrid, drawHeight, drawWidth, paddingTop, paddingLeft]);
+
+  const lastPoint = pointsArr.length === 0 ? null : pointsArr[pointsArr.length - 1];
+
+  const yTicks = useMemo(() => {
+    if (!showAxes) return [];
+    const { min, max } = yDomain;
+    const out: { y: number; label: string }[] = [];
+    if (valueMax === 100 && valueMin === 0 && yTickStep > 0) {
+      for (let p = min; p <= max; p += yTickStep) {
+        out.push({ y: project(p), label: 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: yFormatter(v) });
+    }
+    return out;
+  }, [showAxes, yDomain, valueMax, valueMin, yTickStep, project, yFormatter]);
+
+  const xTicks = useMemo(() => {
+    if (!showAxes) return [];
+    if (nPoints === 0) return [];
+    const m = Math.max(2, tickCountX);
+    const w = drawWidth;
+    const dx = nPoints > 1 ? w / (nPoints - 1) : 0;
+    const out: { x: number; label: string }[] = [];
+    for (let i = 0; i < m; i++) {
+      const idx = Math.round((i * (nPoints - 1)) / (m - 1));
+      const label = labelsSlice[idx] != null ? String(labelsSlice[idx]) : String(idx);
+      const x = Math.round(paddingLeft + idx * dx);
+      out.push({ x, label });
+    }
+    return out;
+  }, [showAxes, labelsSlice, nPoints, tickCountX, drawWidth, paddingLeft]);
+
+  const onMouseMove = useCallback(
+    (evt: MouseEvent<SVGSVGElement>) => {
+      if (!showTooltip || pointsArr.length === 0) return;
+      const rect = evt.currentTarget.getBoundingClientRect();
+      const px = evt.clientX - rect.left;
+      const x = (px / rect.width) * effectiveVbWidth;
+      const dx = nPoints > 1 ? drawWidth / (nPoints - 1) : 0;
+      const idx = Math.max(0, Math.min(nPoints - 1, Math.round((x - paddingLeft) / (dx || 1))));
+      setHoverIdx(idx);
+    },
+    [showTooltip, pointsArr.length, effectiveVbWidth, nPoints, drawWidth, paddingLeft],
+  );
+
+  const onMouseLeave = useCallback(() => setHoverIdx(-1), []);
+
+  const hoverText = useMemo(() => {
+    const idx = hoverIdx;
+    if (idx < 0 || idx >= dataSlice.length) return '';
+    const raw = Number(dataSlice[idx] || 0);
+    const fmt = tooltipFormatter || yFormatter;
+    const val = fmt(Number.isFinite(raw) ? raw : 0);
+    const lab = labelsSlice[idx] != null ? labelsSlice[idx] : '';
+    return `${val}${lab ? ' • ' + lab : ''}`;
+  }, [hoverIdx, dataSlice, labelsSlice, tooltipFormatter, yFormatter]);
+
+  const tooltipPillWidth = Math.max(48, hoverText.length * 6.2 + 14);
+  const hoverPoint = hoverIdx >= 0 ? pointsArr[hoverIdx] : null;
+  const tooltipX = hoverPoint
+    ? Math.max(
+        paddingLeft + 2,
+        Math.min(effectiveVbWidth - paddingRight - tooltipPillWidth - 2, hoverPoint[0] - tooltipPillWidth / 2),
+      )
+    : 0;
+
+  return (
+    <svg
+      ref={svgRef}
+      width="100%"
+      height={height}
+      viewBox={`0 0 ${effectiveVbWidth} ${height}`}
+      preserveAspectRatio="none"
+      className="sparkline-svg"
+      onMouseMove={onMouseMove}
+      onMouseLeave={onMouseLeave}
+    >
+      <defs>
+        <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
+          <stop offset="0%" stopColor={stroke} stopOpacity={Math.min(1, fillOpacity * 1.8)} />
+          <stop offset="50%" stopColor={stroke} stopOpacity={fillOpacity * 0.7} />
+          <stop offset="100%" stopColor={stroke} stopOpacity={0} />
+        </linearGradient>
+        <filter id={shadowId} x="-10%" y="-50%" width="120%" height="200%">
+          <feGaussianBlur in="SourceAlpha" stdDeviation="2.4" />
+          <feOffset dx="0" dy="2" result="offsetBlur" />
+          <feComponentTransfer>
+            <feFuncA type="linear" slope="0.45" />
+          </feComponentTransfer>
+          <feMerge>
+            <feMergeNode />
+            <feMergeNode in="SourceGraphic" />
+          </feMerge>
+        </filter>
+        <radialGradient id={glowId}>
+          <stop offset="0%" stopColor={stroke} stopOpacity="0.55" />
+          <stop offset="100%" stopColor={stroke} stopOpacity="0" />
+        </radialGradient>
+      </defs>
+
+      {showGrid && (
+        <g>
+          {gridLines.map((g, i) => (
+            <line
+              key={i}
+              x1={g.x1}
+              y1={g.y1}
+              x2={g.x2}
+              y2={g.y2}
+              stroke={gridColor}
+              strokeWidth={1}
+              strokeDasharray="3 5"
+              className="cpu-grid-line"
+            />
+          ))}
+        </g>
+      )}
+
+      {showAxes && (
+        <g>
+          {yTicks.map((tk, i) => (
+            <text
+              key={`y${i}`}
+              className="cpu-grid-y-text"
+              x={Math.max(0, paddingLeft - 6)}
+              y={tk.y + 4}
+              textAnchor="end"
+              fontSize={10.5}
+            >
+              {tk.label}
+            </text>
+          ))}
+          {xTicks.map((tk, i) => (
+            <text
+              key={`x${i}`}
+              className="cpu-grid-x-text"
+              x={tk.x}
+              y={paddingTop + drawHeight + 14}
+              textAnchor="middle"
+              fontSize={10.5}
+            >
+              {tk.label}
+            </text>
+          ))}
+        </g>
+      )}
+
+      {areaPath && <path d={areaPath} fill={`url(#${gradId})`} stroke="none" />}
+      <polyline
+        points={pointsStr}
+        fill="none"
+        stroke={stroke}
+        strokeWidth={strokeWidth}
+        strokeLinecap="round"
+        strokeLinejoin="round"
+        filter={`url(#${shadowId})`}
+      />
+      {showMarker && lastPoint && (
+        <>
+          <circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius * 3} fill={`url(#${glowId})`}>
+            <animate attributeName="r" values={`${markerRadius * 2.4};${markerRadius * 3.4};${markerRadius * 2.4}`} dur="2.6s" repeatCount="indefinite" />
+          </circle>
+          <circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius + 1.5} fill={stroke} fillOpacity={0.25} />
+          <circle cx={lastPoint[0]} cy={lastPoint[1]} r={markerRadius} fill={stroke} stroke="#fff" strokeWidth={1.5} />
+        </>
+      )}
+
+      {showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx] && (
+        <g>
+          <line
+            className="cpu-grid-h-line"
+            x1={pointsArr[hoverIdx][0]}
+            x2={pointsArr[hoverIdx][0]}
+            y1={paddingTop}
+            y2={paddingTop + drawHeight}
+            stroke={stroke}
+            strokeOpacity={0.45}
+            strokeWidth={1}
+            strokeDasharray="3 4"
+          />
+          <circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={5} fill={stroke} fillOpacity={0.25} />
+          <circle cx={pointsArr[hoverIdx][0]} cy={pointsArr[hoverIdx][1]} r={3.5} fill={stroke} stroke="#fff" strokeWidth={1.5} />
+          <rect
+            x={tooltipX}
+            y={paddingTop + 2}
+            width={tooltipPillWidth}
+            height={18}
+            rx={9}
+            ry={9}
+            className="cpu-tooltip-pill"
+            fill={stroke}
+            fillOpacity={0.92}
+          />
+          <text
+            className="cpu-tooltip-text"
+            x={tooltipX + tooltipPillWidth / 2}
+            y={paddingTop + 14}
+            textAnchor="middle"
+            fontSize={11}
+            fontWeight={600}
+            fill="#fff"
+          >
+            {hoverText}
+          </text>
+        </g>
+      )}
+    </svg>
+  );
+}

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

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

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

@@ -1,311 +0,0 @@
-<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>

+ 59 - 0
frontend/src/components/TextModal.tsx

@@ -0,0 +1,59 @@
+import { Button, Input, Modal, message } from 'antd';
+import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
+
+import { ClipboardManager, FileManager } from '@/utils';
+
+interface TextModalProps {
+  open: boolean;
+  onClose: () => void;
+  title: string;
+  content: string;
+  fileName?: string;
+}
+
+export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
+  const [messageApi, messageContextHolder] = message.useMessage();
+  async function copy() {
+    const ok = await ClipboardManager.copyText(content || '');
+    if (ok) {
+      messageApi.success('Copied');
+      onClose();
+    }
+  }
+
+  function download() {
+    if (!fileName) return;
+    FileManager.downloadTextFile(content, fileName);
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={title}
+        onCancel={onClose}
+        destroyOnHidden
+      footer={(
+        <>
+          {fileName && (
+            <Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
+          )}
+          <Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
+        </>
+      )}
+    >
+      <Input.TextArea
+        value={content}
+        readOnly
+        autoSize={{ minRows: 10, maxRows: 20 }}
+        style={{
+          fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+          fontSize: 12,
+          overflowY: 'auto',
+        }}
+      />
+      </Modal>
+    </>
+  );
+}

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

@@ -1,66 +0,0 @@
-<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>

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

@@ -1,45 +0,0 @@
-// 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) };
-}

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

@@ -1,26 +0,0 @@
-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 };
-}

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

@@ -1,44 +0,0 @@
-// 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';
-  }
-
-  const hasActive = computed(() => nodes.value.some((n) => n.enable));
-
-  onMounted(refresh);
-
-  return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
-}

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

@@ -1,43 +0,0 @@
-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 };
-}

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

@@ -1,128 +0,0 @@
-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 neutral grey palette modelled on VS Code's
-// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
-// so the panel reads as a familiar modern IDE rather than the older
-// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
-const DARK_TOKENS = {
-  colorBgBase: '#1e1e1e',
-  colorBgLayout: '#1e1e1e',
-  colorBgContainer: '#252526',
-  colorBgElevated: '#2d2d30',
-};
-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. Sider/trigger use the same
-// `#252526` / `#333333` tones VS Code does for its activity bar.
-const DARK_LAYOUT_TOKENS = {
-  colorBgHeader: '#252526',
-  colorBgTrigger: '#333333',
-  colorBgBody: '#1e1e1e',
-};
-const ULTRA_DARK_LAYOUT_TOKENS = {
-  colorBgHeader: '#0a0a0a',
-  colorBgTrigger: '#141414',
-  colorBgBody: '#000',
-};
-const DARK_MENU_TOKENS = {
-  colorItemBg: '#252526',
-  colorSubItemBg: '#1e1e1e',
-  menuSubMenuBg: '#252526',
-};
-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';
-});

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

@@ -1,48 +0,0 @@
-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: ({ type }) => { if (type === '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 };
-}

+ 0 - 21
frontend/src/entries/api-docs.js

@@ -1,21 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/api-docs.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import ApiDocsPage from '@/pages/api-docs/ApiDocsPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <ApiDocsPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,21 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import ClientsPage from '@/pages/clients/ClientsPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(ClientsPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/clients.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import ClientsPage from '@/pages/clients/ClientsPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <ClientsPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,21 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/inbounds.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import InboundsPage from '@/pages/inbounds/InboundsPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <InboundsPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,23 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import IndexPage from '@/pages/index/IndexPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(IndexPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/index.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import IndexPage from '@/pages/index/IndexPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <IndexPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,23 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import LoginPage from '@/pages/login/LoginPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(LoginPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/login.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import LoginPage from '@/pages/login/LoginPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <LoginPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,21 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import NodesPage from '@/pages/nodes/NodesPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(NodesPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/nodes.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import NodesPage from '@/pages/nodes/NodesPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <NodesPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,23 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import SettingsPage from '@/pages/settings/SettingsPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/settings.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import SettingsPage from '@/pages/settings/SettingsPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <SettingsPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,20 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import SubPage from '@/pages/sub/SubPage.vue';
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(SubPage).use(Antd).use(i18n).mount('#app');
-});

+ 23 - 0
frontend/src/entries/subpage.tsx

@@ -0,0 +1,23 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import SubPage from '@/pages/sub/SubPage';
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <SubPage />
+      </ThemeProvider>,
+    );
+  }
+});

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

@@ -1,21 +0,0 @@
-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, readyI18n } from '@/i18n/index.js';
-import { applyDocumentTitle } from '@/utils';
-import XrayPage from '@/pages/xray/XrayPage.vue';
-
-setupAxios();
-applyDocumentTitle();
-
-const messageContainer = document.getElementById('message');
-if (messageContainer) {
-  message.config({ getContainer: () => messageContainer });
-}
-
-readyI18n().then(() => {
-  createApp(XrayPage).use(Antd).use(i18n).mount('#app');
-});

+ 28 - 0
frontend/src/entries/xray.tsx

@@ -0,0 +1,28 @@
+import { createRoot } from 'react-dom/client';
+import { message } from 'antd';
+import 'antd/dist/reset.css';
+
+import { setupAxios } from '@/api/axios-init.js';
+import { applyDocumentTitle } from '@/utils';
+import { readyI18n } from '@/i18n/react';
+import { ThemeProvider } from '@/hooks/useTheme';
+import XrayPage from '@/pages/xray/XrayPage';
+
+setupAxios();
+applyDocumentTitle();
+
+const messageContainer = document.getElementById('message');
+if (messageContainer) {
+  message.config({ getContainer: () => messageContainer });
+}
+
+readyI18n().then(() => {
+  const root = document.getElementById('app');
+  if (root) {
+    createRoot(root).render(
+      <ThemeProvider>
+        <XrayPage />
+      </ThemeProvider>,
+    );
+  }
+});

+ 65 - 0
frontend/src/env.d.ts

@@ -0,0 +1,65 @@
+/// <reference types="vite/client" />
+
+interface SubPageData {
+  sId?: string;
+  enabled?: boolean;
+  download?: string;
+  upload?: string;
+  total?: string;
+  used?: string;
+  remained?: string;
+  totalByte?: string | number;
+  expire?: string | number;
+  lastOnline?: string | number;
+  subUrl?: string;
+  subJsonUrl?: string;
+  subClashUrl?: string;
+  subTitle?: string;
+  links?: string[];
+  datepicker?: 'gregorian' | 'jalalian';
+  downloadByte?: string | number;
+  uploadByte?: string | number;
+  usedByte?: string | number;
+}
+
+interface Window {
+  X_UI_BASE_PATH?: string;
+  X_UI_CUR_VER?: string;
+  __SUB_PAGE_DATA__?: SubPageData;
+}
+
+declare module 'persian-calendar-suite' {
+  import type { ComponentType, ReactNode } from 'react';
+
+  type DateInput = string | number | null;
+  type OutputFormat = 'iso' | 'shamsi' | 'gregorian' | 'hijri' | 'timestamp';
+
+  interface PersianDateTimePickerProps {
+    value?: DateInput;
+    onChange?: (value: number | string | null) => void;
+    defaultValue?: string | number | 'now' | null;
+    showTime?: boolean;
+    minuteStep?: number;
+    outputFormat?: OutputFormat;
+    showFooter?: boolean;
+    theme?: Record<string, unknown>;
+    disabledHours?: number[];
+    minDate?: string | Date | null;
+    maxDate?: string | Date | null;
+    enabledDates?: string[] | null;
+    disabledDates?: string[] | null;
+    disabledWeekDays?: number[];
+    persianNumbers?: boolean;
+    rtlCalendar?: boolean;
+    placeholder?: string;
+    disabled?: boolean;
+    className?: string;
+    children?: ReactNode;
+  }
+
+  export const PersianDateTimePicker: ComponentType<PersianDateTimePickerProps>;
+  export const PersianCalendar: ComponentType<Record<string, unknown>>;
+  export const PersianDateRangePicker: ComponentType<Record<string, unknown>>;
+  export const PersianTimePicker: ComponentType<Record<string, unknown>>;
+  export const PersianTimeline: ComponentType<Record<string, unknown>>;
+}

+ 69 - 0
frontend/src/hooks/useAllSetting.ts

@@ -0,0 +1,69 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { HttpUtil } from '@/utils';
+import { AllSetting } from '@/models/setting';
+
+interface ApiMsg<T = unknown> {
+  success?: boolean;
+  obj?: T;
+}
+
+export function useAllSetting() {
+  const [allSetting, setAllSetting] = useState<AllSetting>(() => new AllSetting());
+  const [oldAllSetting, setOldAllSetting] = useState<AllSetting>(() => new AllSetting());
+  const [fetched, setFetched] = useState(false);
+  const [spinning, setSpinning] = useState(false);
+  const fetchedRef = useRef(false);
+
+  const applyServerState = useCallback((obj: unknown) => {
+    setAllSetting(new AllSetting(obj));
+    setOldAllSetting(new AllSetting(obj));
+  }, []);
+
+  const fetchAll = useCallback(async () => {
+    const msg = await HttpUtil.post('/panel/setting/all') as ApiMsg;
+    if (msg?.success) {
+      applyServerState(msg.obj);
+      fetchedRef.current = true;
+      setFetched(true);
+    }
+  }, [applyServerState]);
+
+  const saveAll = useCallback(async () => {
+    setSpinning(true);
+    try {
+      const msg = await HttpUtil.post('/panel/setting/update', allSetting) as ApiMsg;
+      if (msg?.success) await fetchAll();
+    } finally {
+      setSpinning(false);
+    }
+  }, [allSetting, fetchAll]);
+
+  const updateSetting = useCallback((patch: Partial<AllSetting>) => {
+    setAllSetting((prev) => {
+      const next = new AllSetting(prev);
+      Object.assign(next, patch);
+      return next;
+    });
+  }, []);
+
+  const saveDisabled = useMemo(
+    () => allSetting.equals(oldAllSetting),
+    [allSetting, oldAllSetting],
+  );
+
+  useEffect(() => {
+     
+    fetchAll();
+  }, [fetchAll]);
+
+  return {
+    allSetting,
+    updateSetting,
+    fetched,
+    spinning,
+    setSpinning,
+    saveDisabled,
+    fetchAll,
+    saveAll,
+  };
+}

+ 282 - 0
frontend/src/hooks/useClients.ts

@@ -0,0 +1,282 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { HttpUtil } from '@/utils';
+
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
+
+export interface ClientTraffic {
+  up?: number;
+  down?: number;
+  total?: number;
+  expiryTime?: number;
+  enable?: boolean;
+  lastOnline?: number;
+}
+
+export interface ClientRecord {
+  email: string;
+  subId?: string;
+  uuid?: string;
+  password?: string;
+  auth?: string;
+  flow?: string;
+  totalGB?: number;
+  expiryTime?: number;
+  limitIp?: number;
+  tgId?: number | string;
+  comment?: string;
+  enable?: boolean;
+  inboundIds?: number[];
+  traffic?: ClientTraffic;
+  reverse?: { tag?: string };
+  createdAt?: number;
+  updatedAt?: number;
+  [key: string]: unknown;
+}
+
+export interface InboundOption {
+  id: number;
+  remark?: string;
+  protocol?: string;
+  port?: number;
+  tlsFlowCapable?: boolean;
+}
+
+interface ApiMsg<T = unknown> {
+  success?: boolean;
+  msg?: string;
+  obj?: T;
+}
+
+interface SubSettings {
+  enable: boolean;
+  subURI: string;
+  subJsonURI: string;
+  subJsonEnable: boolean;
+}
+
+export function useClients() {
+  const [clients, setClients] = useState<ClientRecord[]>([]);
+  const [inbounds, setInbounds] = useState<InboundOption[]>([]);
+  const [onlines, setOnlines] = useState<string[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [fetched, setFetched] = useState(false);
+  const [subSettings, setSubSettings] = useState<SubSettings>({
+    enable: false, subURI: '', subJsonURI: '', subJsonEnable: false,
+  });
+  const [ipLimitEnable, setIpLimitEnable] = useState(false);
+  const [tgBotEnable, setTgBotEnable] = useState(false);
+  const [expireDiff, setExpireDiff] = useState(0);
+  const [trafficDiff, setTrafficDiff] = useState(0);
+  const [pageSize, setPageSize] = useState(0);
+
+  const clientsRef = useRef<ClientRecord[]>([]);
+  const invalidateTimerRef = useRef<number | null>(null);
+
+  useEffect(() => { clientsRef.current = clients; }, [clients]);
+
+  const refresh = useCallback(async () => {
+    setLoading(true);
+    try {
+      const [clientsMsg, inboundsMsg] = await Promise.all([
+        HttpUtil.get('/panel/api/clients/list') as Promise<ApiMsg<ClientRecord[]>>,
+        HttpUtil.get('/panel/api/inbounds/options') as Promise<ApiMsg<InboundOption[]>>,
+      ]);
+      if (clientsMsg?.success) {
+        setClients(Array.isArray(clientsMsg.obj) ? clientsMsg.obj : []);
+      }
+      if (inboundsMsg?.success) {
+        setInbounds(Array.isArray(inboundsMsg.obj) ? inboundsMsg.obj : []);
+      }
+      setFetched(true);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const fetchSubSettings = useCallback(async () => {
+    const msg = await HttpUtil.post('/panel/setting/defaultSettings') as ApiMsg<Record<string, unknown>>;
+    if (!msg?.success) return;
+    const s = msg.obj || {};
+    setSubSettings({
+      enable: !!s.subEnable,
+      subURI: (s.subURI as string) || '',
+      subJsonURI: (s.subJsonURI as string) || '',
+      subJsonEnable: !!s.subJsonEnable,
+    });
+    setIpLimitEnable(!!s.ipLimitEnable);
+    setTgBotEnable(!!s.tgBotEnable);
+    setExpireDiff(((s.expireDiff as number) ?? 0) * 86400000);
+    setTrafficDiff(((s.trafficDiff as number) ?? 0) * 1073741824);
+    setPageSize((s.pageSize as number) ?? 0);
+  }, []);
+
+  const create = useCallback(async (payload: unknown) => {
+    const msg = await HttpUtil.post('/panel/api/clients/add', payload, JSON_HEADERS) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const update = useCallback(async (email: string, client: unknown) => {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/update/${encoded}`, client, JSON_HEADERS) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const remove = useCallback(async (email: string, keepTraffic = false) => {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const url = keepTraffic
+      ? `/panel/api/clients/del/${encoded}?keepTraffic=1`
+      : `/panel/api/clients/del/${encoded}`;
+    const msg = await HttpUtil.post(url) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const removeMany = useCallback(async (emails: string[], keepTraffic = false) => {
+    if (!Array.isArray(emails) || emails.length === 0) return [];
+    const suffix = keepTraffic ? '?keepTraffic=1' : '';
+    const results = await Promise.all(emails.map((email) => {
+      const url = `/panel/api/clients/del/${encodeURIComponent(email)}${suffix}`;
+      return HttpUtil.post(url, undefined, { silent: true }) as Promise<ApiMsg>;
+    }));
+    await refresh();
+    return results;
+  }, [refresh]);
+
+  const attach = useCallback(async (email: string, inboundIds: number[]) => {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/attach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const detach = useCallback(async (email: string, inboundIds: number[]) => {
+    if (!email) return null;
+    const encoded = encodeURIComponent(email);
+    const msg = await HttpUtil.post(`/panel/api/clients/${encoded}/detach`, { inboundIds }, JSON_HEADERS) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const resetTraffic = useCallback(async (client: ClientRecord) => {
+    if (!client?.email) return null;
+    const url = `/panel/api/clients/resetTraffic/${encodeURIComponent(client.email)}`;
+    const msg = await HttpUtil.post(url) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const resetAllTraffics = useCallback(async () => {
+    const msg = await HttpUtil.post('/panel/api/clients/resetAllTraffics') as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const delDepleted = useCallback(async () => {
+    const msg = await HttpUtil.post('/panel/api/clients/delDepleted') as ApiMsg<{ deleted?: number }>;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
+    if (!client?.email) return null;
+    const payload = {
+      email: client.email,
+      subId: client.subId,
+      id: client.uuid,
+      password: client.password,
+      auth: client.auth,
+      totalGB: client.totalGB || 0,
+      expiryTime: client.expiryTime || 0,
+      limitIp: client.limitIp || 0,
+      comment: client.comment || '',
+      enable: !!enable,
+    };
+    return update(client.email, payload);
+  }, [update]);
+
+  const applyTrafficEvent = useCallback((payload: unknown) => {
+    if (!payload || typeof payload !== 'object') return;
+    const p = payload as { onlineClients?: string[] };
+    if (Array.isArray(p.onlineClients)) {
+      setOnlines(p.onlineClients);
+    }
+  }, []);
+
+  const applyClientStatsEvent = useCallback((payload: unknown) => {
+    if (!payload || typeof payload !== 'object') return;
+    const p = payload as { clients?: ClientTraffic[] & { email?: string }[] };
+    if (!Array.isArray(p.clients) || p.clients.length === 0) return;
+    const byEmail = new Map<string, ClientTraffic>();
+    for (const row of p.clients as (ClientTraffic & { email?: string })[]) {
+      if (row && row.email) byEmail.set(row.email, row);
+    }
+    const cur = clientsRef.current || [];
+    let touched = false;
+    const next = cur.slice();
+    for (let i = 0; i < next.length; i++) {
+      const row = next[i];
+      const upd = byEmail.get(row?.email);
+      if (!upd) continue;
+      const merged: ClientTraffic = { ...(row.traffic || {}) };
+      if (typeof upd.up === 'number') merged.up = upd.up;
+      if (typeof upd.down === 'number') merged.down = upd.down;
+      if (typeof upd.total === 'number') merged.total = upd.total;
+      if (typeof upd.expiryTime === 'number') merged.expiryTime = upd.expiryTime;
+      if (typeof upd.enable === 'boolean') merged.enable = upd.enable;
+      if (typeof upd.lastOnline === 'number') merged.lastOnline = upd.lastOnline;
+      next[i] = { ...row, traffic: merged };
+      touched = true;
+    }
+    if (touched) setClients(next);
+  }, []);
+
+  const applyInvalidate = useCallback((payload: unknown) => {
+    if (!payload || typeof payload !== 'object') return;
+    const p = payload as { type?: string };
+    if (p.type !== 'inbounds' && p.type !== 'clients') return;
+    if (invalidateTimerRef.current != null) clearTimeout(invalidateTimerRef.current);
+    invalidateTimerRef.current = window.setTimeout(() => {
+      invalidateTimerRef.current = null;
+      refresh();
+    }, 200);
+  }, [refresh]);
+
+  useEffect(() => {
+     
+    Promise.all([refresh(), fetchSubSettings()]);
+     
+  }, [refresh, fetchSubSettings]);
+
+  return {
+    clients,
+    inbounds,
+    onlines,
+    loading,
+    fetched,
+    subSettings,
+    ipLimitEnable,
+    tgBotEnable,
+    expireDiff,
+    trafficDiff,
+    pageSize,
+    refresh,
+    create,
+    update,
+    remove,
+    removeMany,
+    attach,
+    detach,
+    resetTraffic,
+    resetAllTraffics,
+    delDepleted,
+    setEnable,
+    applyTrafficEvent,
+    applyClientStatsEvent,
+    applyInvalidate,
+  };
+}

+ 57 - 0
frontend/src/hooks/useDatepicker.ts

@@ -0,0 +1,57 @@
+import { useEffect, useState } from 'react';
+import { HttpUtil } from '@/utils';
+
+type Calendar = 'gregorian' | 'jalalian';
+
+let cachedValue: Calendar = 'gregorian';
+let fetched = false;
+let pending: Promise<void> | null = null;
+const listeners = new Set<(value: Calendar) => void>();
+
+function notify(value: Calendar) {
+  listeners.forEach((fn) => fn(value));
+}
+
+async function loadOnce(): Promise<void> {
+  if (fetched) return;
+  if (pending) {
+    await pending;
+    return;
+  }
+  pending = (async () => {
+    try {
+      const msg = await HttpUtil.post('/panel/setting/defaultSettings') as {
+        success?: boolean;
+        obj?: { datepicker?: Calendar };
+      };
+      if (msg?.success) {
+        cachedValue = msg.obj?.datepicker || 'gregorian';
+        notify(cachedValue);
+      }
+    } finally {
+      fetched = true;
+      pending = null;
+    }
+  })();
+  await pending;
+}
+
+export function setDatepicker(value: Calendar) {
+  fetched = true;
+  cachedValue = value || 'gregorian';
+  notify(cachedValue);
+}
+
+export function useDatepicker() {
+  const [datepicker, setLocal] = useState<Calendar>(cachedValue);
+
+  useEffect(() => {
+    listeners.add(setLocal);
+    loadOnce();
+    return () => {
+      listeners.delete(setLocal);
+    };
+  }, []);
+
+  return { datepicker };
+}

+ 15 - 0
frontend/src/hooks/useMediaQuery.ts

@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+const MOBILE_BREAKPOINT_PX = 768;
+
+export function useMediaQuery(breakpoint: number = MOBILE_BREAKPOINT_PX) {
+  const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth <= breakpoint);
+
+  useEffect(() => {
+    const onResize = () => setIsMobile(window.innerWidth <= breakpoint);
+    window.addEventListener('resize', onResize);
+    return () => window.removeEventListener('resize', onResize);
+  }, [breakpoint]);
+
+  return { isMobile };
+}

+ 177 - 0
frontend/src/hooks/useNodes.ts

@@ -0,0 +1,177 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { HttpUtil } from '@/utils';
+
+export interface NodeRecord {
+  id: number;
+  name?: string;
+  remark?: string;
+  scheme?: string;
+  address?: string;
+  port?: number;
+  basePath?: string;
+  apiToken?: string;
+  enable?: boolean;
+  status?: 'online' | 'offline' | string;
+  latencyMs?: number;
+  cpuPct?: number;
+  memPct?: number;
+  xrayVersion?: string;
+  panelVersion?: string;
+  uptimeSecs?: number;
+  inboundCount?: number;
+  clientCount?: number;
+  onlineCount?: number;
+  depletedCount?: number;
+  lastHeartbeat?: number;
+  lastError?: string;
+  allowPrivateAddress?: boolean;
+  [key: string]: unknown;
+}
+
+interface ApiMsg<T = unknown> {
+  success?: boolean;
+  msg?: string;
+  obj?: T;
+}
+
+interface NodeTotals {
+  total: number;
+  online: number;
+  offline: number;
+  avgLatency: number;
+  inbounds: number;
+  clients: number;
+  onlineClients: number;
+  depleted: number;
+}
+
+export function useNodes() {
+  const [nodes, setNodes] = useState<NodeRecord[]>([]);
+  const [loading, setLoading] = useState(false);
+  const [fetched, setFetched] = useState(false);
+  const fetchedRef = useRef(false);
+
+  const refresh = useCallback(async () => {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.get('/panel/api/nodes/list') as ApiMsg<NodeRecord[]>;
+      if (msg?.success) {
+        setNodes(Array.isArray(msg.obj) ? msg.obj : []);
+      }
+      fetchedRef.current = true;
+      setFetched(true);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  const applyNodesEvent = useCallback((payload: unknown) => {
+    if (Array.isArray(payload)) {
+      setNodes(payload as NodeRecord[]);
+      if (!fetchedRef.current) {
+        fetchedRef.current = true;
+        setFetched(true);
+      }
+    }
+  }, []);
+
+  const create = useCallback(async (payload: Partial<NodeRecord>) => {
+    const msg = await HttpUtil.post('/panel/api/nodes/add', payload) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const update = useCallback(async (id: number, payload: Partial<NodeRecord>) => {
+    const msg = await HttpUtil.post(`/panel/api/nodes/update/${id}`, payload) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const remove = useCallback(async (id: number) => {
+    const msg = await HttpUtil.post(`/panel/api/nodes/del/${id}`) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const setEnable = useCallback(async (id: number, enable: boolean) => {
+    const msg = await HttpUtil.post(`/panel/api/nodes/setEnable/${id}`, { enable }) as ApiMsg;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const testConnection = useCallback(async (payload: Partial<NodeRecord>) => {
+    return await HttpUtil.post('/panel/api/nodes/test', payload) as ApiMsg<{
+      status: string;
+      latencyMs?: number;
+      xrayVersion?: string;
+      error?: string;
+    }>;
+  }, []);
+
+  const probe = useCallback(async (id: number) => {
+    const msg = await HttpUtil.post(`/panel/api/nodes/probe/${id}`) as ApiMsg<{
+      status: string;
+      latencyMs?: number;
+      error?: string;
+    }>;
+    if (msg?.success) await refresh();
+    return msg;
+  }, [refresh]);
+
+  const totals = useMemo<NodeTotals>(() => {
+    let online = 0;
+    let offline = 0;
+    let latencySum = 0;
+    let latencyCount = 0;
+    let inbounds = 0;
+    let clients = 0;
+    let onlineClients = 0;
+    let depleted = 0;
+    for (const n of nodes) {
+      inbounds += n.inboundCount || 0;
+      clients += n.clientCount || 0;
+      onlineClients += n.onlineCount || 0;
+      depleted += n.depletedCount || 0;
+      if (!n.enable) continue;
+      if (n.status === 'online') {
+        online += 1;
+        if (n.latencyMs && n.latencyMs > 0) {
+          latencySum += n.latencyMs;
+          latencyCount += 1;
+        }
+      } else if (n.status === 'offline') {
+        offline += 1;
+      }
+    }
+    return {
+      total: nodes.length,
+      online,
+      offline,
+      avgLatency: latencyCount > 0 ? Math.round(latencySum / latencyCount) : 0,
+      inbounds,
+      clients,
+      onlineClients,
+      depleted,
+    };
+  }, [nodes]);
+
+  useEffect(() => {
+     
+    refresh();
+  }, [refresh]);
+
+  return {
+    nodes,
+    loading,
+    fetched,
+    totals,
+    refresh,
+    applyNodesEvent,
+    create,
+    update,
+    remove,
+    setEnable,
+    testConnection,
+    probe,
+  };
+}

+ 35 - 0
frontend/src/hooks/useStatus.ts

@@ -0,0 +1,35 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { HttpUtil } from '@/utils';
+import { Status } from '@/models/status';
+
+const POLL_INTERVAL_MS = 2000;
+
+export function useStatus() {
+  const [status, setStatus] = useState<Status>(() => new Status());
+  const [fetched, setFetched] = useState(false);
+  const fetchedRef = useRef(false);
+
+  const refresh = useCallback(async () => {
+    try {
+      const msg = await HttpUtil.get('/panel/api/server/status');
+      if (msg?.success) {
+        setStatus(new Status(msg.obj));
+        if (!fetchedRef.current) {
+          fetchedRef.current = true;
+          setFetched(true);
+        }
+      }
+    } catch (e) {
+      console.error('Failed to get status:', e);
+    }
+  }, []);
+
+  useEffect(() => {
+    refresh();
+    const timer = window.setInterval(refresh, POLL_INTERVAL_MS);
+    return () => window.clearInterval(timer);
+  }, [refresh]);
+
+  return { status, fetched, refresh };
+}

+ 136 - 0
frontend/src/hooks/useTheme.tsx

@@ -0,0 +1,136 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import type { ReactNode } from 'react';
+import { theme as antdTheme } from 'antd';
+import type { ThemeConfig } from 'antd';
+
+const STORAGE_DARK = 'dark-mode';
+const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
+
+function readBool(key: string, fallback: boolean): boolean {
+  const raw = localStorage.getItem(key);
+  if (raw === null) return fallback;
+  return raw === 'true';
+}
+
+function applyDom(isDark: boolean, isUltra: boolean) {
+  document.body.setAttribute('class', isDark ? 'dark' : 'light');
+  if (isUltra) {
+    document.documentElement.setAttribute('data-theme', 'ultra-dark');
+  } else {
+    document.documentElement.removeAttribute('data-theme');
+  }
+  const msg = document.getElementById('message');
+  if (msg) msg.className = isDark ? 'dark' : 'light';
+}
+
+// module load so the document is in the right theme before React mounts.
+const initialDark = readBool(STORAGE_DARK, true);
+const initialUltra = readBool(STORAGE_ULTRA, false);
+applyDom(initialDark, initialUltra);
+
+const DARK_TOKENS = {
+  colorBgBase: '#1a1b1f',
+  colorBgLayout: '#1a1b1f',
+  colorBgContainer: '#23252b',
+  colorBgElevated: '#2d2f37',
+};
+const ULTRA_DARK_TOKENS = {
+  colorBgBase: '#000',
+  colorBgLayout: '#000',
+  colorBgContainer: '#101013',
+  colorBgElevated: '#1a1a1e',
+};
+const DARK_LAYOUT_TOKENS = {
+  bodyBg: '#1a1b1f',
+  headerBg: '#15161a',
+  headerColor: '#ffffff',
+  footerBg: '#1a1b1f',
+  siderBg: '#15161a',
+  triggerBg: '#23252b',
+  triggerColor: '#ffffff',
+};
+const ULTRA_DARK_LAYOUT_TOKENS = {
+  bodyBg: '#000',
+  headerBg: '#050507',
+  headerColor: '#ffffff',
+  footerBg: '#000',
+  siderBg: '#050507',
+  triggerBg: '#1a1a1e',
+  triggerColor: '#ffffff',
+};
+const DARK_MENU_TOKENS = {
+  darkItemBg: '#15161a',
+  darkSubMenuItemBg: '#1a1b1f',
+  darkPopupBg: '#23252b',
+};
+const ULTRA_DARK_MENU_TOKENS = {
+  darkItemBg: '#050507',
+  darkSubMenuItemBg: '#000',
+  darkPopupBg: '#101013',
+};
+
+export function buildAntdThemeConfig(isDark: boolean, isUltra: boolean): ThemeConfig {
+  if (!isDark) {
+    return { algorithm: antdTheme.defaultAlgorithm };
+  }
+  return {
+    algorithm: antdTheme.darkAlgorithm,
+    token: isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
+    components: {
+      Layout: isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
+      Menu: isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
+    },
+  };
+}
+
+export function pauseAnimationsUntilLeave(elementId: string): void {
+  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);
+}
+
+interface ThemeContextValue {
+  isDark: boolean;
+  isUltra: boolean;
+  toggleTheme: () => void;
+  toggleUltra: () => void;
+  antdThemeConfig: ThemeConfig;
+}
+
+const ThemeContext = createContext<ThemeContextValue | null>(null);
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+  const [isDark, setIsDark] = useState<boolean>(initialDark);
+  const [isUltra, setIsUltra] = useState<boolean>(initialUltra);
+
+  useEffect(() => {
+    applyDom(isDark, isUltra);
+    localStorage.setItem(STORAGE_DARK, String(isDark));
+    localStorage.setItem(STORAGE_ULTRA, String(isUltra));
+  }, [isDark, isUltra]);
+
+  const toggleTheme = useCallback(() => setIsDark((v) => !v), []);
+  const toggleUltra = useCallback(() => setIsUltra((v) => !v), []);
+
+  const antdThemeConfig = useMemo(() => buildAntdThemeConfig(isDark, isUltra), [isDark, isUltra]);
+
+  const value = useMemo<ThemeContextValue>(
+    () => ({ isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig }),
+    [isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig],
+  );
+
+  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
+}
+
+export function useTheme(): ThemeContextValue {
+  const ctx = useContext(ThemeContext);
+  if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
+  return ctx;
+}

+ 32 - 0
frontend/src/hooks/useWebSocket.ts

@@ -0,0 +1,32 @@
+import { useEffect } from 'react';
+import { WebSocketClient } from '@/api/websocket.js';
+
+type Handler = (payload: unknown) => void;
+
+interface SharedClient {
+  connect(): void;
+  on(event: string, fn: Handler): void;
+  off(event: string, fn: Handler): void;
+}
+
+let sharedClient: SharedClient | null = null;
+
+function getSharedClient(): SharedClient {
+  if (sharedClient) return sharedClient;
+  const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
+  sharedClient = new WebSocketClient(basePath) as SharedClient;
+  return sharedClient;
+}
+
+export function useWebSocket(handlers: Record<string, Handler>) {
+  useEffect(() => {
+    const client = getSharedClient();
+    const entries = Object.entries(handlers);
+    for (const [event, fn] of entries) client.on(event, fn);
+    client.connect();
+    return () => {
+      for (const [event, fn] of entries) client.off(event, fn);
+    };
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+}

+ 370 - 0
frontend/src/hooks/useXraySetting.ts

@@ -0,0 +1,370 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import { HttpUtil, PromiseUtil } from '@/utils';
+
+const DIRTY_POLL_MS = 1000;
+
+export interface OutboundTrafficRow {
+  tag: string;
+  up: number;
+  down: number;
+}
+
+export interface OutboundTestResult {
+  success: boolean;
+  delay?: number;
+  error?: string;
+  mode?: string;
+  ttfbMs?: number;
+  tlsMs?: number;
+  connectMs?: number;
+  dnsMs?: number;
+  statusCode?: number;
+  endpoints?: { address: string; delay?: number; success: boolean; error?: string }[];
+}
+
+export interface OutboundTestState {
+  testing?: boolean;
+  result?: OutboundTestResult | null;
+  mode?: string;
+}
+
+export interface XraySettingsValue {
+  inbounds?: unknown[];
+  outbounds?: { tag?: string; protocol?: string; settings?: unknown; streamSettings?: unknown }[];
+  routing?: {
+    rules?: { type?: string; outboundTag?: string; balancerTag?: string; [key: string]: unknown }[];
+    balancers?: unknown[];
+    domainStrategy?: string;
+  };
+  dns?: { tag?: string; servers?: unknown[] };
+  log?: Record<string, unknown>;
+  policy?: { system?: Record<string, boolean> };
+  observatory?: unknown;
+  burstObservatory?: unknown;
+  fakedns?: unknown;
+  [key: string]: unknown;
+}
+
+export type SetTemplate = (
+  next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
+) => void;
+
+export interface UseXraySettingResult {
+  fetched: boolean;
+  spinning: boolean;
+  saveDisabled: boolean;
+  fetchError: string;
+  xraySetting: string;
+  setXraySetting: (next: string) => void;
+  templateSettings: XraySettingsValue | null;
+  setTemplateSettings: SetTemplate;
+  outboundTestUrl: string;
+  setOutboundTestUrl: (v: string) => void;
+  inboundTags: string[];
+  clientReverseTags: string[];
+  restartResult: string;
+  outboundsTraffic: OutboundTrafficRow[];
+  outboundTestStates: Record<number, OutboundTestState>;
+  testingAll: boolean;
+  fetchAll: () => Promise<void>;
+  fetchOutboundsTraffic: () => Promise<void>;
+  resetOutboundsTraffic: (tag: string) => Promise<void>;
+  applyOutboundsEvent: (payload: unknown) => void;
+  testOutbound: (
+    index: number,
+    outbound: unknown,
+    mode?: string,
+  ) => Promise<OutboundTestResult | null>;
+  testAllOutbounds: (mode?: string) => Promise<void>;
+  saveAll: () => Promise<void>;
+  resetToDefault: () => Promise<void>;
+  restartXray: () => Promise<void>;
+}
+
+export function useXraySetting(): UseXraySettingResult {
+  const [fetched, setFetched] = useState(false);
+  const [spinning, setSpinning] = useState(false);
+  const [saveDisabled, setSaveDisabled] = useState(true);
+  const [fetchError, setFetchError] = useState('');
+  const [xraySetting, setXraySettingState] = useState('');
+  const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
+  const [outboundTestUrl, setOutboundTestUrlState] = useState('https://www.google.com/generate_204');
+  const [inboundTags, setInboundTags] = useState<string[]>([]);
+  const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
+  const [restartResult, setRestartResult] = useState('');
+  const [outboundsTraffic, setOutboundsTraffic] = useState<OutboundTrafficRow[]>([]);
+  const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
+  const [testingAll, setTestingAll] = useState(false);
+
+  const oldXraySettingRef = useRef('');
+  const oldOutboundTestUrlRef = useRef('');
+  const syncingRef = useRef(false);
+  const xraySettingRef = useRef('');
+  const outboundTestUrlRef = useRef(outboundTestUrl);
+  const templateSettingsRef = useRef<XraySettingsValue | null>(null);
+
+  xraySettingRef.current = xraySetting;
+  outboundTestUrlRef.current = outboundTestUrl;
+  templateSettingsRef.current = templateSettings;
+
+  const setXraySetting = useCallback((next: string) => {
+    setXraySettingState(next);
+    if (syncingRef.current) return;
+    try {
+      const parsed = JSON.parse(next);
+      syncingRef.current = true;
+      setTemplateSettingsState(parsed);
+      syncingRef.current = false;
+    } catch {
+      /* ignore — wait for user to finish */
+    }
+  }, []);
+
+  const setTemplateSettings: SetTemplate = useCallback((nextOrFn) => {
+    setTemplateSettingsState((prev) => {
+      const next = typeof nextOrFn === 'function' ? nextOrFn(prev) : nextOrFn;
+      if (next == null) return next;
+      if (!syncingRef.current) {
+        try {
+          syncingRef.current = true;
+          setXraySettingState(JSON.stringify(next, null, 2));
+        } finally {
+          syncingRef.current = false;
+        }
+      }
+      return next;
+    });
+  }, []);
+
+  const setOutboundTestUrl = useCallback((v: string) => {
+    setOutboundTestUrlState(v);
+  }, []);
+
+  const fetchAll = useCallback(async () => {
+    setFetchError('');
+    const msg = await HttpUtil.post('/panel/xray/');
+    if (!msg?.success) {
+      setFetchError(msg?.msg || 'Failed to load xray config');
+      setFetched(true);
+      return;
+    }
+    let obj;
+    try {
+      obj = JSON.parse(msg.obj);
+    } catch (e) {
+      const err = e as Error;
+      setFetchError(`Malformed xray config response: ${err?.message || String(err)}`);
+      setFetched(true);
+      return;
+    }
+    const pretty = JSON.stringify(obj.xraySetting, null, 2);
+    syncingRef.current = true;
+    setXraySettingState(pretty);
+    setTemplateSettingsState(obj.xraySetting);
+    oldXraySettingRef.current = pretty;
+    syncingRef.current = false;
+    setInboundTags(obj.inboundTags || []);
+    setClientReverseTags(obj.clientReverseTags || []);
+    const nextUrl = obj.outboundTestUrl || 'https://www.google.com/generate_204';
+    setOutboundTestUrlState(nextUrl);
+    oldOutboundTestUrlRef.current = nextUrl;
+    setFetched(true);
+    setSaveDisabled(true);
+  }, []);
+
+  const saveAll = useCallback(async () => {
+    setSpinning(true);
+    try {
+      const msg = await HttpUtil.post('/panel/xray/update', {
+        xraySetting: xraySettingRef.current,
+        outboundTestUrl: outboundTestUrlRef.current || 'https://www.google.com/generate_204',
+      });
+      if (msg?.success) await fetchAll();
+    } finally {
+      setSpinning(false);
+    }
+  }, [fetchAll]);
+
+  const fetchOutboundsTraffic = useCallback(async () => {
+    const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic');
+    if (msg?.success) setOutboundsTraffic(msg.obj || []);
+  }, []);
+
+  const resetOutboundsTraffic = useCallback(async (tag: string) => {
+    const msg = await HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag });
+    if (msg?.success) await fetchOutboundsTraffic();
+  }, [fetchOutboundsTraffic]);
+
+  const applyOutboundsEvent = useCallback((payload: unknown) => {
+    if (Array.isArray(payload)) setOutboundsTraffic(payload as OutboundTrafficRow[]);
+  }, []);
+
+  const testOutbound = useCallback(
+    async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
+      if (!outbound) return null;
+      setOutboundTestStates((prev) => ({
+        ...prev,
+        [index]: { testing: true, result: null, mode },
+      }));
+      try {
+        const msg = await HttpUtil.post('/panel/xray/testOutbound', {
+          outbound: JSON.stringify(outbound),
+          allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
+          mode,
+        });
+        if (msg?.success) {
+          setOutboundTestStates((prev) => ({
+            ...prev,
+            [index]: { testing: false, result: msg.obj },
+          }));
+          return msg.obj;
+        }
+        setOutboundTestStates((prev) => ({
+          ...prev,
+          [index]: {
+            testing: false,
+            result: { success: false, error: msg?.msg || 'Unknown error', mode },
+          },
+        }));
+      } catch (e) {
+        setOutboundTestStates((prev) => ({
+          ...prev,
+          [index]: {
+            testing: false,
+            result: { success: false, error: String(e), mode },
+          },
+        }));
+      }
+      return null;
+    },
+    [],
+  );
+
+  const testAllOutbounds = useCallback(async (mode = 'tcp') => {
+    const list = templateSettingsRef.current?.outbounds || [];
+    if (list.length === 0 || testingAll) return;
+    setTestingAll(true);
+    try {
+      const concurrency = mode === 'tcp' ? 8 : 1;
+      const queue = list
+        .map((ob, i) => ({ index: i, outbound: ob }))
+        .filter(({ outbound }) => {
+          const tag = outbound?.tag;
+          const proto = outbound?.protocol;
+          if (proto === 'blackhole' || proto === 'loopback' || tag === 'blocked') return false;
+          if (mode === 'tcp' && (proto === 'freedom' || proto === 'dns')) return false;
+          return true;
+        });
+      async function worker() {
+        while (queue.length > 0) {
+          const item = queue.shift();
+          if (!item) break;
+          await testOutbound(item.index, item.outbound, mode);
+        }
+      }
+      const workers = Array.from(
+        { length: Math.min(concurrency, queue.length) },
+        () => worker(),
+      );
+      await Promise.all(workers);
+    } finally {
+      setTestingAll(false);
+    }
+  }, [testingAll, testOutbound]);
+
+  const resetToDefault = useCallback(async () => {
+    setSpinning(true);
+    try {
+      const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
+      if (msg?.success) {
+        const cloned = JSON.parse(JSON.stringify(msg.obj));
+        setTemplateSettings(cloned);
+      }
+    } finally {
+      setSpinning(false);
+    }
+  }, [setTemplateSettings]);
+
+  const restartXray = useCallback(async () => {
+    setSpinning(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
+      if (msg?.success) {
+        await PromiseUtil.sleep(500);
+        const r = await HttpUtil.get('/panel/xray/getXrayResult');
+        if (r?.success) setRestartResult(r.obj || '');
+      }
+    } finally {
+      setSpinning(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    fetchAll();
+    fetchOutboundsTraffic();
+    const timer = window.setInterval(() => {
+      const dirtyXray = oldXraySettingRef.current !== xraySettingRef.current;
+      const dirtyUrl = oldOutboundTestUrlRef.current !== outboundTestUrlRef.current;
+      setSaveDisabled(!(dirtyXray || dirtyUrl));
+    }, DIRTY_POLL_MS);
+    return () => window.clearInterval(timer);
+  }, [fetchAll, fetchOutboundsTraffic]);
+
+  return useMemo(
+    () => ({
+      fetched,
+      spinning,
+      saveDisabled,
+      fetchError,
+      xraySetting,
+      setXraySetting,
+      templateSettings,
+      setTemplateSettings,
+      outboundTestUrl,
+      setOutboundTestUrl,
+      inboundTags,
+      clientReverseTags,
+      restartResult,
+      outboundsTraffic,
+      outboundTestStates,
+      testingAll,
+      fetchAll,
+      fetchOutboundsTraffic,
+      resetOutboundsTraffic,
+      applyOutboundsEvent,
+      testOutbound,
+      testAllOutbounds,
+      saveAll,
+      resetToDefault,
+      restartXray,
+    }),
+    [
+      fetched,
+      spinning,
+      saveDisabled,
+      fetchError,
+      xraySetting,
+      setXraySetting,
+      templateSettings,
+      setTemplateSettings,
+      outboundTestUrl,
+      setOutboundTestUrl,
+      inboundTags,
+      clientReverseTags,
+      restartResult,
+      outboundsTraffic,
+      outboundTestStates,
+      testingAll,
+      fetchAll,
+      fetchOutboundsTraffic,
+      resetOutboundsTraffic,
+      applyOutboundsEvent,
+      testOutbound,
+      testAllOutbounds,
+      saveAll,
+      resetToDefault,
+      restartXray,
+    ],
+  );
+}

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

@@ -1,54 +0,0 @@
-import { createI18n } from 'vue-i18n';
-
-import { LanguageManager } from '@/utils';
-import enUS from '../../../web/translation/en-US.json';
-
-const FALLBACK = 'en-US';
-const lazyModules = import.meta.glob([
-  '../../../web/translation/*.json',
-  '!../../../web/translation/en-US.json',
-]);
-
-function moduleKeyFor(code) {
-  return `../../../web/translation/${code}.json`;
-}
-
-let active = LanguageManager.getLanguage();
-if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
-  active = FALLBACK;
-}
-
-export const i18n = createI18n({
-  legacy: false,
-  globalInjection: true,
-  locale: active,
-  fallbackLocale: FALLBACK,
-  messages: { [FALLBACK]: enUS },
-  warnHtmlMessage: false,
-  missingWarn: false,
-  fallbackWarn: false,
-});
-
-export function t(key, params) {
-  return i18n.global.t(key, params || {});
-}
-
-export async function loadLocale(code) {
-  if (code === FALLBACK) {
-    i18n.global.locale.value = FALLBACK;
-    return true;
-  }
-  const loader = lazyModules[moduleKeyFor(code)];
-  if (!loader) return false;
-  const mod = await loader();
-  i18n.global.setLocaleMessage(code, mod.default || mod);
-  i18n.global.locale.value = code;
-  return true;
-}
-
-export async function readyI18n() {
-  if (active !== FALLBACK) {
-    await loadLocale(active);
-  }
-  return i18n;
-}

+ 43 - 0
frontend/src/i18n/react.ts

@@ -0,0 +1,43 @@
+import i18next from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import { LanguageManager } from '@/utils';
+import enUS from '../../../web/translation/en-US.json';
+
+const FALLBACK = 'en-US';
+
+const lazyModules = import.meta.glob([
+  '../../../web/translation/*.json',
+  '!../../../web/translation/en-US.json',
+]);
+
+function moduleKeyFor(code: string): string {
+  return `../../../web/translation/${code}.json`;
+}
+
+let active: string = LanguageManager.getLanguage();
+if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
+  active = FALLBACK;
+}
+
+export async function readyI18n() {
+  await i18next.use(initReactI18next).init({
+    lng: active,
+    fallbackLng: FALLBACK,
+    resources: { [FALLBACK]: { translation: enUS } },
+    interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
+    returnNull: false,
+  });
+  if (active !== FALLBACK) {
+    const loader = lazyModules[moduleKeyFor(active)] as (() => Promise<{ default: Record<string, unknown> }>) | undefined;
+    if (loader) {
+      const mod = await loader();
+      const messages = (mod.default ?? mod) as Record<string, unknown>;
+      i18next.addResourceBundle(active, 'translation', messages, true, true);
+      await i18next.changeLanguage(active);
+    }
+  }
+  return i18next;
+}
+
+export { i18next as i18n };

+ 0 - 108
frontend/src/models/setting.js

@@ -1,108 +0,0 @@
-// 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 = "";
-        this.webDomain = "";
-        this.webPort = 2053;
-        this.webCertFile = "";
-        this.webKeyFile = "";
-        this.webBasePath = "/";
-        this.sessionMaxAge = 360;
-        this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
-        this.pageSize = 25;
-        this.expireDiff = 0;
-        this.trafficDiff = 0;
-        this.remarkModel = "-ieo";
-        this.datepicker = "gregorian";
-        this.tgBotEnable = false;
-        this.tgBotToken = "";
-        this.tgBotProxy = "";
-        this.tgBotAPIServer = "";
-        this.tgBotChatId = "";
-        this.tgRunTime = "@daily";
-        this.tgBotBackup = false;
-        this.tgBotLoginNotify = true;
-        this.tgCpu = 80;
-        this.tgLang = "en-US";
-        this.twoFactorEnable = false;
-        this.twoFactorToken = "";
-        this.xrayTemplateConfig = "";
-        this.subEnable = true;
-        this.subJsonEnable = false;
-        this.subTitle = "";
-        this.subSupportUrl = "";
-        this.subProfileUrl = "";
-        this.subAnnounce = "";
-        this.subEnableRouting = true;
-        this.subRoutingRules = "";
-        this.subListen = "";
-        this.subPort = 2096;
-        this.subPath = "/sub/";
-        this.subJsonPath = "/json/";
-        this.subClashEnable = true;
-        this.subClashPath = "/clash/";
-        this.subDomain = "";
-        this.externalTrafficInformEnable = false;
-        this.externalTrafficInformURI = "";
-        this.restartXrayOnClientDisable = true;
-        this.subCertFile = "";
-        this.subKeyFile = "";
-        this.subUpdates = 12;
-        this.subEncrypt = true;
-        this.subShowInfo = true;
-        this.subEmailInRemark = true;
-        this.subURI = "";
-        this.subJsonURI = "";
-        this.subClashURI = "";
-        this.subJsonFragment = "";
-        this.subJsonNoises = "";
-        this.subJsonMux = "";
-        this.subJsonRules = "";
-
-        this.timeLocation = "Local";
-
-        // LDAP settings
-        this.ldapEnable = false;
-        this.ldapHost = "";
-        this.ldapPort = 389;
-        this.ldapUseTLS = false;
-        this.ldapBindDN = "";
-        this.ldapPassword = "";
-        this.ldapBaseDN = "";
-        this.ldapUserFilter = "(objectClass=person)";
-        this.ldapUserAttr = "mail";
-        this.ldapVlessField = "vless_enabled";
-        this.ldapSyncCron = "@every 1m";
-        this.ldapFlagField = "";
-        this.ldapTruthyValues = "true,1,yes,on";
-        this.ldapInvertFlag = false;
-        this.ldapInboundTags = "";
-        this.ldapAutoCreate = false;
-        this.ldapAutoDelete = false;
-        this.ldapDefaultTotalGB = 0;
-        this.ldapDefaultExpiryDays = 0;
-        this.ldapDefaultLimitIP = 0;
-        this.hasTgBotToken = false;
-        this.hasTwoFactorToken = false;
-        this.hasLdapPassword = false;
-        this.hasApiToken = false;
-        this.hasWarpSecret = false;
-        this.hasNordSecret = false;
-
-        if (data == null) {
-            return
-        }
-        ObjectUtil.cloneProps(this, data);
-    }
-
-    equals(other) {
-        return ObjectUtil.equals(this, other);
-    }
-}

+ 100 - 0
frontend/src/models/setting.ts

@@ -0,0 +1,100 @@
+import { ObjectUtil } from '@/utils';
+
+export class AllSetting {
+  webListen = '';
+  webDomain = '';
+  webPort = 2053;
+  webCertFile = '';
+  webKeyFile = '';
+  webBasePath = '/';
+  sessionMaxAge = 360;
+  trustedProxyCIDRs = '127.0.0.1/32,::1/128';
+  pageSize = 25;
+  expireDiff = 0;
+  trafficDiff = 0;
+  remarkModel = '-ieo';
+  datepicker: 'gregorian' | 'jalalian' = 'gregorian';
+  tgBotEnable = false;
+  tgBotToken = '';
+  tgBotProxy = '';
+  tgBotAPIServer = '';
+  tgBotChatId = '';
+  tgRunTime = '@daily';
+  tgBotBackup = false;
+  tgBotLoginNotify = true;
+  tgCpu = 80;
+  tgLang = 'en-US';
+  twoFactorEnable = false;
+  twoFactorToken = '';
+  xrayTemplateConfig = '';
+  subEnable = true;
+  subJsonEnable = false;
+  subTitle = '';
+  subSupportUrl = '';
+  subProfileUrl = '';
+  subAnnounce = '';
+  subEnableRouting = true;
+  subRoutingRules = '';
+  subListen = '';
+  subPort = 2096;
+  subPath = '/sub/';
+  subJsonPath = '/json/';
+  subClashEnable = true;
+  subClashPath = '/clash/';
+  subDomain = '';
+  externalTrafficInformEnable = false;
+  externalTrafficInformURI = '';
+  restartXrayOnClientDisable = true;
+  subCertFile = '';
+  subKeyFile = '';
+  subUpdates = 12;
+  subEncrypt = true;
+  subShowInfo = true;
+  subEmailInRemark = true;
+  subURI = '';
+  subJsonURI = '';
+  subClashURI = '';
+  subJsonFragment = '';
+  subJsonNoises = '';
+  subJsonMux = '';
+  subJsonRules = '';
+
+  timeLocation = 'Local';
+
+  ldapEnable = false;
+  ldapHost = '';
+  ldapPort = 389;
+  ldapUseTLS = false;
+  ldapBindDN = '';
+  ldapPassword = '';
+  ldapBaseDN = '';
+  ldapUserFilter = '(objectClass=person)';
+  ldapUserAttr = 'mail';
+  ldapVlessField = 'vless_enabled';
+  ldapSyncCron = '@every 1m';
+  ldapFlagField = '';
+  ldapTruthyValues = 'true,1,yes,on';
+  ldapInvertFlag = false;
+  ldapInboundTags = '';
+  ldapAutoCreate = false;
+  ldapAutoDelete = false;
+  ldapDefaultTotalGB = 0;
+  ldapDefaultExpiryDays = 0;
+  ldapDefaultLimitIP = 0;
+  hasTgBotToken = false;
+  hasTwoFactorToken = false;
+  hasLdapPassword = false;
+  hasApiToken = false;
+  hasWarpSecret = false;
+  hasNordSecret = false;
+
+  constructor(data?: unknown) {
+    if (data != null) {
+      ObjectUtil.cloneProps(this, data);
+    }
+  }
+
+  equals(other: AllSetting): boolean {
+    return ObjectUtil.equals(this, other);
+  }
+}

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

@@ -1,71 +0,0 @@
-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',
-};
-
-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', 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';
-  }
-}

+ 120 - 0
frontend/src/models/status.ts

@@ -0,0 +1,120 @@
+import { NumberFormatter } from '@/utils';
+
+export class CurTotal {
+  current: number;
+  total: number;
+
+  constructor(current: number, total: number) {
+    this.current = current;
+    this.total = total;
+  }
+
+  get percent(): number {
+    if (this.total === 0) return 0;
+    return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
+  }
+
+  get color(): string {
+    const p = this.percent;
+    if (p < 80) return '#1677ff';
+    if (p < 90) return '#faad14';
+    return '#ff4d4f';
+  }
+}
+
+const XRAY_STATE_COLORS: Record<string, string> = {
+  running: 'green',
+  stop: 'orange',
+  error: 'red',
+};
+
+export interface NetIO {
+  up: number;
+  down: number;
+}
+
+export interface NetTraffic {
+  sent: number;
+  recv: number;
+}
+
+export interface PublicIP {
+  ipv4: string | number;
+  ipv6: string | number;
+}
+
+export interface AppStats {
+  threads: number;
+  mem: number;
+  uptime: number;
+}
+
+export interface XrayInfo {
+  state: 'running' | 'stop' | 'error' | string;
+  errorMsg: string;
+  version: string;
+  color: string;
+}
+
+interface StatusInput {
+  cpu?: number;
+  cpuCores?: number;
+  logicalPro?: number;
+  cpuSpeedMhz?: number;
+  disk?: { current?: number; total?: number };
+  loads?: number[];
+  mem?: { current?: number; total?: number };
+  netIO?: NetIO;
+  netTraffic?: NetTraffic;
+  publicIP?: PublicIP;
+  swap?: { current?: number; total?: number };
+  tcpCount?: number;
+  udpCount?: number;
+  uptime?: number;
+  appUptime?: number;
+  appStats?: AppStats;
+  xray?: Partial<XrayInfo>;
+}
+
+export class Status {
+  cpu: CurTotal = new CurTotal(0, 0);
+  cpuCores = 0;
+  logicalPro = 0;
+  cpuSpeedMhz = 0;
+  disk: CurTotal = new CurTotal(0, 0);
+  loads: number[] = [0, 0, 0];
+  mem: CurTotal = new CurTotal(0, 0);
+  netIO: NetIO = { up: 0, down: 0 };
+  netTraffic: NetTraffic = { sent: 0, recv: 0 };
+  publicIP: PublicIP = { ipv4: 0, ipv6: 0 };
+  swap: CurTotal = new CurTotal(0, 0);
+  tcpCount = 0;
+  udpCount = 0;
+  uptime = 0;
+  appUptime = 0;
+  appStats: AppStats = { threads: 0, mem: 0, uptime: 0 };
+  xray: XrayInfo = { state: 'stop', errorMsg: '', version: '', color: '' };
+
+  constructor(data?: StatusInput | null) {
+    if (data == null) return;
+
+    this.cpu = new CurTotal(data.cpu ?? 0, 100);
+    this.cpuCores = data.cpuCores ?? 0;
+    this.logicalPro = data.logicalPro ?? 0;
+    this.cpuSpeedMhz = data.cpuSpeedMhz ?? 0;
+    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';
+  }
+}

+ 292 - 0
frontend/src/pages/api-docs/ApiDocsPage.css

@@ -0,0 +1,292 @@
+.api-docs-page {
+  --bg-page: #e6e8ec;
+  --bg-card: #ffffff;
+  min-height: 100vh;
+  background: var(--bg-page);
+}
+
+.api-docs-page.is-dark {
+  --bg-page: #1a1b1f;
+  --bg-card: #23252b;
+}
+
+.api-docs-page.is-dark.is-ultra {
+  --bg-page: #000;
+  --bg-card: #101013;
+}
+
+.api-docs-page .content-shell {
+  background: var(--bg-page);
+}
+
+.api-docs-page .content-area {
+  padding: 24px;
+  max-width: 100%;
+}
+
+@media (max-width: 768px) {
+  .api-docs-page .content-area {
+    padding: 16px 12px 12px;
+    padding-top: 64px;
+  }
+}
+
+.docs-wrapper {
+  max-width: 1100px;
+  margin: 0 auto;
+}
+
+.docs-header {
+  margin-bottom: 20px;
+  padding: 24px;
+  background: var(--bg-card);
+  border: 1px solid rgba(128, 128, 128, 0.12);
+  border-radius: 10px;
+}
+
+.docs-title {
+  font-size: 28px;
+  font-weight: 800;
+  margin: 0 0 8px;
+  color: rgba(0, 0, 0, 0.88);
+  letter-spacing: -0.3px;
+}
+
+.docs-lead {
+  margin: 0;
+  color: rgba(0, 0, 0, 0.65);
+  line-height: 1.65;
+  font-size: 14px;
+}
+
+.docs-lead code,
+.token-hint code {
+  background: rgba(128, 128, 128, 0.12);
+  padding: 1px 6px;
+  border-radius: 4px;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 12.5px;
+}
+
+.token-card,
+.curl-card {
+  margin-bottom: 16px;
+}
+
+.token-card-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 10px;
+  min-height: 32px;
+}
+
+.token-card-title {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 600;
+  font-size: 14px;
+}
+
+.token-hint {
+  margin: 10px 0 0;
+  color: rgba(0, 0, 0, 0.55);
+  font-size: 12.5px;
+  line-height: 1.55;
+}
+
+.toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-bottom: 16px;
+}
+
+.search-bar {
+  flex: 1;
+  min-width: 200px;
+  max-width: 480px;
+}
+
+.match-count {
+  font-size: 12px;
+  color: rgba(0, 0, 0, 0.5);
+  white-space: nowrap;
+}
+
+.toc-nav {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: flex-start;
+  gap: 8px 12px;
+  padding: 12px 16px;
+  background: var(--bg-card);
+  border: 1px solid rgba(128, 128, 128, 0.12);
+  border-radius: 8px;
+  margin-bottom: 16px;
+}
+
+.toc-label {
+  font-size: 11px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.6px;
+  color: rgba(0, 0, 0, 0.5);
+  padding-top: 3px;
+  flex-shrink: 0;
+}
+
+.toc-links {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+}
+
+.toc-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
+  padding: 4px 10px;
+  border-radius: 20px;
+  font-size: 12.5px;
+  color: rgba(0, 0, 0, 0.65);
+  background: rgba(128, 128, 128, 0.06);
+  border: 1px solid transparent;
+  text-decoration: none;
+  cursor: pointer;
+  transition: all 0.2s;
+  white-space: nowrap;
+}
+
+.toc-link:hover {
+  background: rgba(22, 119, 255, 0.08);
+  color: #1677ff;
+  border-color: rgba(22, 119, 255, 0.2);
+}
+
+.toc-link.active {
+  background: rgba(22, 119, 255, 0.12);
+  color: #1677ff;
+  border-color: rgba(22, 119, 255, 0.3);
+  font-weight: 600;
+}
+
+.toc-icon {
+  font-size: 13px;
+  opacity: 0.8;
+}
+
+.toc-text {
+  font-size: 12.5px;
+}
+
+.toc-badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 18px;
+  height: 18px;
+  padding: 0 5px;
+  border-radius: 9px;
+  font-size: 10.5px;
+  font-weight: 700;
+  background: rgba(22, 119, 255, 0.12);
+  color: #1677ff;
+  line-height: 1;
+}
+
+.toc-link.active .toc-badge {
+  background: #1677ff;
+  color: #fff;
+}
+
+body.dark .docs-title {
+  color: rgba(255, 255, 255, 0.92);
+}
+
+html[data-theme='ultra-dark'] .docs-title {
+  color: rgba(255, 255, 255, 0.95);
+}
+
+body.dark .docs-header {
+  background: #252526;
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+html[data-theme='ultra-dark'] .docs-header {
+  background: #0a0a0a;
+  border-color: rgba(255, 255, 255, 0.06);
+}
+
+body.dark .docs-lead,
+body.dark .token-hint {
+  color: rgba(255, 255, 255, 0.7);
+}
+
+html[data-theme='ultra-dark'] .docs-lead,
+html[data-theme='ultra-dark'] .token-hint {
+  color: rgba(255, 255, 255, 0.75);
+}
+
+body.dark .docs-lead code,
+body.dark .token-hint code {
+  background: rgba(255, 255, 255, 0.1);
+}
+
+html[data-theme='ultra-dark'] .docs-lead code,
+html[data-theme='ultra-dark'] .token-hint code {
+  background: rgba(255, 255, 255, 0.12);
+}
+
+body.dark .toc-nav {
+  background: #252526;
+  border-color: rgba(255, 255, 255, 0.08);
+}
+
+html[data-theme='ultra-dark'] .toc-nav {
+  background: #0a0a0a;
+  border-color: rgba(255, 255, 255, 0.06);
+}
+
+body.dark .toc-label {
+  color: rgba(255, 255, 255, 0.55);
+}
+
+html[data-theme='ultra-dark'] .toc-label {
+  color: rgba(255, 255, 255, 0.6);
+}
+
+body.dark .toc-link {
+  color: rgba(255, 255, 255, 0.65);
+  background: rgba(255, 255, 255, 0.06);
+}
+
+html[data-theme='ultra-dark'] .toc-link {
+  background: rgba(255, 255, 255, 0.04);
+}
+
+body.dark .toc-link:hover {
+  background: rgba(88, 166, 255, 0.12);
+  color: #58a6ff;
+  border-color: rgba(88, 166, 255, 0.25);
+}
+
+body.dark .toc-link.active {
+  background: rgba(88, 166, 255, 0.15);
+  color: #58a6ff;
+  border-color: rgba(88, 166, 255, 0.35);
+}
+
+body.dark .toc-badge {
+  background: rgba(88, 166, 255, 0.15);
+  color: #58a6ff;
+}
+
+body.dark .toc-link.active .toc-badge {
+  background: #58a6ff;
+  color: #0d1117;
+}

+ 247 - 0
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -0,0 +1,247 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { ComponentType, MouseEvent } from 'react';
+import { Button, Card, ConfigProvider, Input, Layout, Space } from 'antd';
+import {
+  ApiOutlined,
+  CloudServerOutlined,
+  ClusterOutlined,
+  CompressOutlined,
+  ExpandOutlined,
+  GlobalOutlined,
+  KeyOutlined,
+  LinkOutlined,
+  NodeIndexOutlined,
+  SafetyCertificateOutlined,
+  SaveOutlined,
+  SearchOutlined,
+  SettingOutlined,
+  WifiOutlined,
+} from '@ant-design/icons';
+
+import { useTheme } from '@/hooks/useTheme';
+import AppSidebar from '@/components/AppSidebar';
+import { sections as allSections } from './endpoints.js';
+import EndpointSection from './EndpointSection';
+import type { Section } from './EndpointSection';
+import CodeBlock from './CodeBlock';
+import '@/styles/page-cards.css';
+import './ApiDocsPage.css';
+
+const sectionIcons: Record<string, ComponentType<{ className?: string }>> = {
+  authentication: SafetyCertificateOutlined,
+  inbounds: NodeIndexOutlined,
+  server: CloudServerOutlined,
+  nodes: ClusterOutlined,
+  'custom-geo': GlobalOutlined,
+  backup: SaveOutlined,
+  settings: SettingOutlined,
+  'api-tokens': KeyOutlined,
+  'xray-settings': WifiOutlined,
+  subscription: LinkOutlined,
+  websocket: ApiOutlined,
+};
+
+const curlExample = `curl -X GET \\
+  -H "Authorization: Bearer YOUR_API_TOKEN" \\
+  -H "Accept: application/json" \\
+  https://your-panel.example.com/panel/api/inbounds/list`;
+
+const basePath = window.X_UI_BASE_PATH || '';
+const requestUri = window.location.pathname;
+const settingsHref = `${basePath}panel/settings#security`;
+
+const endpointCount = (allSections as Section[]).reduce(
+  (sum, s) => sum + s.endpoints.length,
+  0,
+);
+
+export default function ApiDocsPage() {
+  const { isDark, isUltra, antdThemeConfig } = useTheme();
+
+  const [searchQuery, setSearchQuery] = useState('');
+  const [collapsedSections, setCollapsedSections] = useState<Set<string>>(() => new Set());
+  const [activeSection, setActiveSection] = useState('');
+
+  const sections = useMemo<Section[]>(() => {
+    const q = searchQuery.toLowerCase().trim();
+    if (!q) return allSections as Section[];
+    return (allSections as Section[])
+      .map((s) => ({
+        ...s,
+        endpoints: s.endpoints.filter((e) =>
+          e.path.toLowerCase().includes(q)
+          || e.summary?.toLowerCase().includes(q)
+          || e.method.toLowerCase().includes(q),
+        ),
+      }))
+      .filter((s) => s.endpoints.length > 0);
+  }, [searchQuery]);
+
+  const visibleEndpoints = useMemo(
+    () => sections.reduce((sum, s) => sum + s.endpoints.length, 0),
+    [sections],
+  );
+
+  const toggleSection = useCallback((id: string) => {
+    setCollapsedSections((prev) => {
+      const next = new Set(prev);
+      if (next.has(id)) next.delete(id); else next.add(id);
+      return next;
+    });
+  }, []);
+
+  const expandAll = useCallback(() => setCollapsedSections(new Set()), []);
+  const collapseAll = useCallback(
+    () => setCollapsedSections(new Set((allSections as Section[]).map((s) => s.id))),
+    [],
+  );
+
+  const scrollToSection = useCallback((id: string) => (e: MouseEvent) => {
+    e.preventDefault();
+    const el = document.getElementById(id);
+    if (!el) return;
+    el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    if (window.location.hash !== `#${id}`) {
+      history.replaceState(null, '', `#${id}`);
+    }
+  }, []);
+
+  useEffect(() => {
+    const onHashChange = () => {
+      const id = window.location.hash.slice(1);
+      if (!id) return;
+      const el = document.getElementById(id);
+      if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
+    };
+    requestAnimationFrame(onHashChange);
+    window.addEventListener('hashchange', onHashChange);
+    return () => window.removeEventListener('hashchange', onHashChange);
+  }, []);
+
+  useEffect(() => {
+    const onScroll = () => {
+      const toc = document.querySelector('.toc-nav');
+      const tocHeight = toc instanceof HTMLElement ? toc.offsetHeight : 56;
+      let current = '';
+      for (const s of sections) {
+        const el = document.getElementById(s.id);
+        if (!el) continue;
+        const rect = el.getBoundingClientRect();
+        if (rect.top <= tocHeight + 20) {
+          current = s.id;
+        }
+      }
+      setActiveSection(current);
+    };
+    window.addEventListener('scroll', onScroll, { passive: true });
+    requestAnimationFrame(onScroll);
+    return () => window.removeEventListener('scroll', onScroll);
+  }, [sections]);
+
+  const pageClass = useMemo(() => {
+    const classes = ['api-docs-page'];
+    if (isDark) classes.push('is-dark');
+    if (isUltra) classes.push('is-ultra');
+    return classes.join(' ');
+  }, [isDark, isUltra]);
+
+  return (
+    <ConfigProvider theme={antdThemeConfig}>
+      <Layout className={pageClass}>
+        <AppSidebar basePath={basePath} requestUri={requestUri} />
+
+        <Layout className="content-shell">
+          <Layout.Content className="content-area">
+            <div className="docs-wrapper">
+              <header className="docs-header">
+                <h1 className="docs-title">API Documentation</h1>
+                <p className="docs-lead">
+                  The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
+                  cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
+                  returns a uniform <code>{'{ success, msg, obj }'}</code> envelope unless otherwise noted.
+                </p>
+              </header>
+
+              <Card className="token-card" size="small">
+                <div className="token-card-head">
+                  <div className="token-card-title">
+                    <KeyOutlined />
+                    <span>API Tokens</span>
+                  </div>
+                  <Button type="primary" size="small" href={settingsHref}>
+                    Manage tokens
+                  </Button>
+                </div>
+                <p className="token-hint">
+                  Create, enable, or revoke named Bearer tokens in{' '}
+                  <a href={settingsHref}>Settings → Security</a>. Send each request as{' '}
+                  <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don&apos;t
+                  need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
+                </p>
+              </Card>
+
+              <Card className="curl-card" size="small" title="Quick example">
+                <CodeBlock code={curlExample} lang="text" />
+              </Card>
+
+              <div className="toolbar">
+                <Input
+                  className="search-bar"
+                  prefix={<SearchOutlined />}
+                  placeholder="Search endpoints by path, method, or description…"
+                  allowClear
+                  value={searchQuery}
+                  onChange={(e) => setSearchQuery(e.target.value)}
+                />
+                {searchQuery && (
+                  <span className="match-count">
+                    {visibleEndpoints} / {endpointCount} endpoints
+                  </span>
+                )}
+                <Space size="small">
+                  <Button size="small" icon={<ExpandOutlined />} onClick={expandAll}>
+                    Expand all
+                  </Button>
+                  <Button size="small" icon={<CompressOutlined />} onClick={collapseAll}>
+                    Collapse all
+                  </Button>
+                </Space>
+              </div>
+
+              <nav className="toc-nav">
+                <span className="toc-label">On this page:</span>
+                <div className="toc-links">
+                  {sections.map((s) => {
+                    const Icon = sectionIcons[s.id];
+                    return (
+                      <a
+                        key={s.id}
+                        className={`toc-link${activeSection === s.id ? ' active' : ''}`}
+                        href={`#${s.id}`}
+                        onClick={scrollToSection(s.id)}
+                      >
+                        {Icon && <Icon />}
+                        <span className="toc-text">{s.title}</span>
+                        <span className="toc-badge">{s.endpoints.length}</span>
+                      </a>
+                    );
+                  })}
+                </div>
+              </nav>
+
+              {sections.map((s) => (
+                <EndpointSection
+                  key={s.id}
+                  section={s}
+                  icon={sectionIcons[s.id]}
+                  collapsed={collapsedSections.has(s.id)}
+                  onToggle={() => toggleSection(s.id)}
+                />
+              ))}
+            </div>
+          </Layout.Content>
+        </Layout>
+      </Layout>
+    </ConfigProvider>
+  );
+}

+ 0 - 561
frontend/src/pages/api-docs/ApiDocsPage.vue

@@ -1,561 +0,0 @@
-<script setup>
-import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
-import {
-  KeyOutlined,
-  SearchOutlined,
-  ExpandOutlined,
-  CompressOutlined,
-  ApiOutlined,
-  SafetyCertificateOutlined,
-  CloudServerOutlined,
-  ClusterOutlined,
-  GlobalOutlined,
-  SaveOutlined,
-  SettingOutlined,
-  WifiOutlined,
-  LinkOutlined,
-  NodeIndexOutlined,
-} from '@ant-design/icons-vue';
-
-import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
-import AppSidebar from '@/components/AppSidebar.vue';
-import { sections as allSections } from './endpoints.js';
-import EndpointSection from './EndpointSection.vue';
-import CodeBlock from './CodeBlock.vue';
-
-const basePath = window.X_UI_BASE_PATH || '';
-const requestUri = window.location.pathname;
-const settingsHref = `${basePath}panel/settings#security`;
-
-const searchQuery = ref('');
-const collapsedSections = ref(new Set());
-const activeSection = ref('');
-
-const sectionIcons = {
-  authentication: SafetyCertificateOutlined,
-  inbounds: NodeIndexOutlined,
-  server: CloudServerOutlined,
-  nodes: ClusterOutlined,
-  'custom-geo': GlobalOutlined,
-  backup: SaveOutlined,
-  settings: SettingOutlined,
-  'api-tokens': KeyOutlined,
-  'xray-settings': WifiOutlined,
-  subscription: LinkOutlined,
-  websocket: ApiOutlined,
-};
-
-const curlExample = `curl -X GET \\
-  -H "Authorization: Bearer YOUR_API_TOKEN" \\
-  -H "Accept: application/json" \\
-  https://your-panel.example.com/panel/api/inbounds/list`;
-
-const sections = computed(() => {
-  const q = searchQuery.value.toLowerCase().trim();
-  if (!q) return allSections;
-  return allSections
-    .map(s => {
-      const matching = s.endpoints.filter(e =>
-        e.path.toLowerCase().includes(q) ||
-        e.summary?.toLowerCase().includes(q) ||
-        e.method.toLowerCase().includes(q)
-      );
-      return { ...s, endpoints: matching };
-    })
-    .filter(s => s.endpoints.length > 0);
-});
-
-const endpointCount = computed(() =>
-  allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
-);
-
-const visibleEndpoints = computed(() =>
-  sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
-);
-
-function isCollapsed(id) {
-  return collapsedSections.value.has(id);
-}
-
-function toggleSection(id) {
-  const s = new Set(collapsedSections.value);
-  if (s.has(id)) s.delete(id); else s.add(id);
-  collapsedSections.value = s;
-}
-
-function expandAll() {
-  collapsedSections.value = new Set();
-}
-
-function collapseAll() {
-  collapsedSections.value = new Set(allSections.map(s => s.id));
-}
-
-function scrollToSection(id) {
-  const el = document.getElementById(id);
-  if (!el) return;
-  el.scrollIntoView({ behavior: 'smooth', block: 'start' });
-  if (window.location.hash !== `#${id}`) {
-    history.replaceState(null, '', `#${id}`);
-  }
-}
-
-function scrollToHash() {
-  const id = window.location.hash.slice(1);
-  if (!id) return;
-  const el = document.getElementById(id);
-  if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
-}
-
-let scrollObserver = null;
-function onScroll() {
-  const toc = document.querySelector('.toc-nav');
-  const tocHeight = toc ? toc.offsetHeight : 56;
-  let current = '';
-  for (const s of sections.value) {
-    const el = document.getElementById(s.id);
-    if (!el) continue;
-    const rect = el.getBoundingClientRect();
-    if (rect.top <= tocHeight + 20) {
-      current = s.id;
-    }
-  }
-  activeSection.value = current;
-}
-
-onMounted(() => {
-  scrollObserver = onScroll;
-  window.addEventListener('scroll', scrollObserver, { passive: true });
-  window.addEventListener('hashchange', scrollToHash);
-  requestAnimationFrame(() => {
-    scrollToHash();
-    onScroll();
-  });
-});
-
-onBeforeUnmount(() => {
-  if (scrollObserver) {
-    window.removeEventListener('scroll', scrollObserver);
-  }
-  window.removeEventListener('hashchange', scrollToHash);
-});
-</script>
-
-<template>
-  <a-config-provider :theme="antdThemeConfig">
-    <a-layout class="api-docs-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">
-          <div class="docs-wrapper">
-            <header class="docs-header">
-              <h1 class="docs-title">API Documentation</h1>
-              <p class="docs-lead">
-                The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
-                cookie, or with the <code>Authorization: Bearer &lt;token&gt;</code> header below. Every endpoint
-                returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
-              </p>
-
-            </header>
-
-            <a-card class="token-card" size="small">
-              <div class="token-card-head">
-                <div class="token-card-title">
-                  <KeyOutlined />
-                  <span>API Tokens</span>
-                </div>
-                <a-button type="primary" size="small" :href="settingsHref">
-                  Manage tokens
-                </a-button>
-              </div>
-              <p class="token-hint">
-                Create, enable, or revoke named Bearer tokens in
-                <a :href="settingsHref">Settings → Security</a>. Send each request as
-                <code>Authorization: Bearer &lt;token&gt;</code>. Token-authenticated callers skip CSRF and don't
-                need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
-              </p>
-            </a-card>
-
-            <a-card class="curl-card" size="small" title="Quick example">
-              <CodeBlock :code="curlExample" lang="text" />
-            </a-card>
-
-            <div class="toolbar">
-              <a-input-search
-                v-model:value="searchQuery"
-                placeholder="Search endpoints by path, method, or description…"
-                allow-clear
-                class="search-bar"
-              >
-                <template #prefix><SearchOutlined /></template>
-              </a-input-search>
-              <span class="match-count" v-if="searchQuery">
-                {{ visibleEndpoints }} / {{ endpointCount }} endpoints
-              </span>
-              <a-space size="small">
-                <a-button size="small" @click="expandAll">
-                  <template #icon><ExpandOutlined /></template>
-                  Expand all
-                </a-button>
-                <a-button size="small" @click="collapseAll">
-                  <template #icon><CompressOutlined /></template>
-                  Collapse all
-                </a-button>
-              </a-space>
-            </div>
-
-            <nav class="toc-nav">
-              <span class="toc-label">On this page:</span>
-              <div class="toc-links">
-                <a
-                  v-for="s in sections"
-                  :key="s.id"
-                  class="toc-link"
-                  :class="{ active: activeSection === s.id }"
-                  :href="`#${s.id}`"
-                  @click.prevent="scrollToSection(s.id)"
-                >
-                  <component :is="sectionIcons[s.id]" class="toc-icon" />
-                  <span class="toc-text">{{ s.title }}</span>
-                  <span class="toc-badge">{{ s.endpoints.length }}</span>
-                </a>
-              </div>
-            </nav>
-
-            <EndpointSection
-              v-for="s in sections"
-              :key="s.id"
-              :section="s"
-              :icon="sectionIcons[s.id]"
-              :collapsed="isCollapsed(s.id)"
-              @toggle="toggleSection(s.id)"
-            />
-          </div>
-        </a-layout-content>
-      </a-layout>
-    </a-layout>
-  </a-config-provider>
-</template>
-
-<style scoped>
-.api-docs-page {
-  --bg-page: #e6e8ec;
-  --bg-card: #ffffff;
-  min-height: 100vh;
-  background: var(--bg-page);
-}
-
-.api-docs-page.is-dark {
-  --bg-page: #1e1e1e;
-  --bg-card: #252526;
-}
-
-.api-docs-page.is-dark.is-ultra {
-  --bg-page: #000;
-  --bg-card: #0a0a0a;
-}
-
-.content-shell {
-  background: var(--bg-page);
-}
-
-.content-area {
-  padding: 24px;
-  max-width: 100%;
-}
-
-@media (max-width: 768px) {
-  .content-area {
-    padding: 16px 12px 12px;
-    padding-top: 64px;
-  }
-}
-
-.docs-wrapper {
-  max-width: 1100px;
-  margin: 0 auto;
-}
-
-.docs-header {
-  margin-bottom: 20px;
-  padding: 24px;
-  background: var(--bg-card);
-  border: 1px solid rgba(128, 128, 128, 0.12);
-  border-radius: 10px;
-}
-
-.docs-title {
-  font-size: 28px;
-  font-weight: 800;
-  margin: 0 0 8px;
-  color: rgba(0, 0, 0, 0.88);
-  letter-spacing: -0.3px;
-}
-
-.docs-lead {
-  margin: 0;
-  color: rgba(0, 0, 0, 0.65);
-  line-height: 1.65;
-  font-size: 14px;
-}
-
-.docs-lead code,
-.token-hint code {
-  background: rgba(128, 128, 128, 0.12);
-  padding: 1px 6px;
-  border-radius: 4px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 12.5px;
-}
-
-.token-card,
-.curl-card {
-  margin-bottom: 16px;
-}
-
-.token-card-head {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-  flex-wrap: wrap;
-  margin-bottom: 10px;
-  min-height: 32px;
-}
-
-.token-card-title {
-  display: inline-flex;
-  align-items: center;
-  gap: 8px;
-  font-weight: 600;
-  font-size: 14px;
-}
-
-.token-hint {
-  margin: 10px 0 0;
-  color: rgba(0, 0, 0, 0.55);
-  font-size: 12.5px;
-  line-height: 1.55;
-}
-
-.code-block {
-  background: rgba(128, 128, 128, 0.08);
-  border: 1px solid rgba(128, 128, 128, 0.15);
-  border-radius: 6px;
-  padding: 10px 12px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 12.5px;
-  line-height: 1.55;
-  margin: 0;
-  white-space: pre-wrap;
-  word-break: break-word;
-  overflow-x: auto;
-}
-
-.toolbar {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-  flex-wrap: wrap;
-  margin-bottom: 16px;
-}
-
-.search-bar {
-  flex: 1;
-  min-width: 200px;
-  max-width: 480px;
-}
-
-.match-count {
-  font-size: 12px;
-  color: rgba(0, 0, 0, 0.5);
-  white-space: nowrap;
-}
-
-.toc-nav {
-  display: flex;
-  flex-wrap: wrap;
-  align-items: flex-start;
-  gap: 8px 12px;
-  padding: 12px 16px;
-  background: var(--bg-card);
-  border: 1px solid rgba(128, 128, 128, 0.12);
-  border-radius: 8px;
-  margin-bottom: 16px;
-}
-
-.toc-label {
-  font-size: 11px;
-  font-weight: 600;
-  text-transform: uppercase;
-  letter-spacing: 0.6px;
-  color: rgba(0, 0, 0, 0.5);
-  padding-top: 3px;
-  flex-shrink: 0;
-}
-
-.toc-links {
-  display: flex;
-  flex-wrap: wrap;
-  gap: 6px;
-}
-
-.toc-link {
-  display: inline-flex;
-  align-items: center;
-  gap: 5px;
-  padding: 4px 10px;
-  border-radius: 20px;
-  font-size: 12.5px;
-  color: rgba(0, 0, 0, 0.65);
-  background: rgba(128, 128, 128, 0.06);
-  border: 1px solid transparent;
-  text-decoration: none;
-  cursor: pointer;
-  transition: all 0.2s;
-  white-space: nowrap;
-}
-
-.toc-link:hover {
-  background: rgba(22, 119, 255, 0.08);
-  color: #1677ff;
-  border-color: rgba(22, 119, 255, 0.2);
-}
-
-.toc-link.active {
-  background: rgba(22, 119, 255, 0.12);
-  color: #1677ff;
-  border-color: rgba(22, 119, 255, 0.3);
-  font-weight: 600;
-}
-
-.toc-icon {
-  font-size: 13px;
-  opacity: 0.8;
-}
-
-.toc-text {
-  font-size: 12.5px;
-}
-
-.toc-badge {
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  min-width: 18px;
-  height: 18px;
-  padding: 0 5px;
-  border-radius: 9px;
-  font-size: 10.5px;
-  font-weight: 700;
-  background: rgba(22, 119, 255, 0.12);
-  color: #1677ff;
-  line-height: 1;
-}
-
-.toc-link.active .toc-badge {
-  background: #1677ff;
-  color: #fff;
-}
-</style>
-
-<style>
-body.dark .docs-title {
-  color: rgba(255, 255, 255, 0.92);
-}
-
-html[data-theme='ultra-dark'] .docs-title {
-  color: rgba(255, 255, 255, 0.95);
-}
-
-body.dark .docs-header {
-  background: #252526;
-  border-color: rgba(255, 255, 255, 0.08);
-}
-
-html[data-theme='ultra-dark'] .docs-header {
-  background: #0a0a0a;
-  border-color: rgba(255, 255, 255, 0.06);
-}
-
-body.dark .docs-lead,
-body.dark .token-hint {
-  color: rgba(255, 255, 255, 0.7);
-}
-
-html[data-theme='ultra-dark'] .docs-lead,
-html[data-theme='ultra-dark'] .token-hint {
-  color: rgba(255, 255, 255, 0.75);
-}
-
-body.dark .docs-lead code,
-body.dark .token-hint code {
-  background: rgba(255, 255, 255, 0.1);
-}
-
-html[data-theme='ultra-dark'] .docs-lead code,
-html[data-theme='ultra-dark'] .token-hint code {
-  background: rgba(255, 255, 255, 0.12);
-}
-
-body.dark .code-block {
-  background: rgba(255, 255, 255, 0.04);
-  border-color: rgba(255, 255, 255, 0.1);
-  color: rgba(255, 255, 255, 0.88);
-}
-
-html[data-theme='ultra-dark'] .code-block {
-  background: rgba(255, 255, 255, 0.02);
-  border-color: rgba(255, 255, 255, 0.08);
-}
-
-body.dark .toc-nav {
-  background: #252526;
-  border-color: rgba(255, 255, 255, 0.08);
-}
-
-html[data-theme='ultra-dark'] .toc-nav {
-  background: #0a0a0a;
-  border-color: rgba(255, 255, 255, 0.06);
-}
-
-body.dark .toc-label {
-  color: rgba(255, 255, 255, 0.55);
-}
-
-html[data-theme='ultra-dark'] .toc-label {
-  color: rgba(255, 255, 255, 0.6);
-}
-
-body.dark .toc-link {
-  color: rgba(255, 255, 255, 0.65);
-  background: rgba(255, 255, 255, 0.06);
-}
-
-html[data-theme='ultra-dark'] .toc-link {
-  background: rgba(255, 255, 255, 0.04);
-}
-
-body.dark .toc-link:hover {
-  background: rgba(88, 166, 255, 0.12);
-  color: #58a6ff;
-  border-color: rgba(88, 166, 255, 0.25);
-}
-
-body.dark .toc-link.active {
-  background: rgba(88, 166, 255, 0.15);
-  color: #58a6ff;
-  border-color: rgba(88, 166, 255, 0.35);
-}
-
-body.dark .toc-badge {
-  background: rgba(88, 166, 255, 0.15);
-  color: #58a6ff;
-}
-
-body.dark .toc-link.active .toc-badge {
-  background: #58a6ff;
-  color: #0d1117;
-}
-</style>

+ 0 - 67
frontend/src/pages/api-docs/CodeBlock.vue → frontend/src/pages/api-docs/CodeBlock.css

@@ -1,67 +1,3 @@
-<script setup>
-import { computed, ref } from 'vue';
-import { message } from 'ant-design-vue';
-import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
-
-const props = defineProps({
-  code: { type: String, default: '' },
-  lang: { type: String, default: 'json' },
-});
-
-const copied = ref(false);
-
-function escapeHtml(str) {
-  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
-}
-
-function highlightJson(str) {
-  const escaped = escapeHtml(str);
-  return escaped.replace(
-    /("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
-    (_m, key, colon, string, number, bool, nil) => {
-      if (colon) return `<span class="json-key">${key}</span>${colon}`;
-      if (string) return `<span class="json-string">${string}</span>`;
-      if (number) return `<span class="json-number">${number}</span>`;
-      if (bool) return `<span class="json-boolean">${bool}</span>`;
-      if (nil) return `<span class="json-null">${nil}</span>`;
-      return _m;
-    }
-  );
-}
-
-const highlighted = computed(() => {
-  if (props.lang === 'json') {
-    return highlightJson(props.code);
-  }
-  return escapeHtml(props.code);
-});
-
-async function copyCode() {
-  try {
-    await navigator.clipboard.writeText(props.code);
-    copied.value = true;
-    message.success('Copied');
-    setTimeout(() => { copied.value = false; }, 2000);
-  } catch {
-    message.error('Copy failed');
-  }
-}
-</script>
-
-<template>
-  <div class="code-block-wrapper">
-    <div class="code-toolbar">
-      <span class="lang-badge">{{ lang.toUpperCase() }}</span>
-      <button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
-        <CheckOutlined v-if="copied" />
-        <CopyOutlined v-else />
-      </button>
-    </div>
-    <pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
-  </div>
-</template>
-
-<style scoped>
 .code-block-wrapper {
   position: relative;
   border-radius: 6px;
@@ -127,9 +63,7 @@ async function copyCode() {
   border: none;
   border-radius: 0;
 }
-</style>
 
-<style>
 .json-key { color: #0550ae; }
 .json-string { color: #116329; }
 .json-number { color: #9a6700; }
@@ -171,4 +105,3 @@ body.dark .copy-btn:hover {
   color: #58a6ff;
   border-color: #58a6ff;
 }
-</style>

+ 69 - 0
frontend/src/pages/api-docs/CodeBlock.tsx

@@ -0,0 +1,69 @@
+import { useMemo, useState } from 'react';
+import { message } from 'antd';
+import { CheckOutlined, CopyOutlined } from '@ant-design/icons';
+import { ClipboardManager } from '@/utils';
+import './CodeBlock.css';
+
+interface CodeBlockProps {
+  code?: string;
+  lang?: string;
+}
+
+function escapeHtml(str: string): string {
+  return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+}
+
+function highlightJson(str: string): string {
+  const escaped = escapeHtml(str);
+  return escaped.replace(
+    /("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
+    (_m, key, colon, string, number, bool, nil) => {
+      if (colon) return `<span class="json-key">${key}</span>${colon}`;
+      if (string) return `<span class="json-string">${string}</span>`;
+      if (number) return `<span class="json-number">${number}</span>`;
+      if (bool) return `<span class="json-boolean">${bool}</span>`;
+      if (nil) return `<span class="json-null">${nil}</span>`;
+      return _m;
+    },
+  );
+}
+
+export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
+  const [copied, setCopied] = useState(false);
+  const [messageApi, messageContextHolder] = message.useMessage();
+
+  const highlighted = useMemo(
+    () => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
+    [code, lang],
+  );
+
+  async function copyCode() {
+    const ok = await ClipboardManager.copyText(code);
+    if (ok) {
+      setCopied(true);
+      messageApi.success('Copied');
+      window.setTimeout(() => setCopied(false), 2000);
+    } else {
+      messageApi.error('Copy failed');
+    }
+  }
+
+  return (
+    <div className="code-block-wrapper">
+      {messageContextHolder}
+      <div className="code-toolbar">
+        <span className="lang-badge">{lang.toUpperCase()}</span>
+        <button
+          className={`copy-btn${copied ? ' copied' : ''}`}
+          onClick={copyCode}
+          title={copied ? 'Copied' : 'Copy'}
+        >
+          {copied ? <CheckOutlined /> : <CopyOutlined />}
+        </button>
+      </div>
+      <pre className={`code-block lang-${lang}`}>
+        <code dangerouslySetInnerHTML={{ __html: highlighted }} />
+      </pre>
+    </div>
+  );
+}

+ 93 - 0
frontend/src/pages/api-docs/EndpointRow.css

@@ -0,0 +1,93 @@
+.endpoint-row {
+  padding: 14px 8px;
+  margin: 0 -8px;
+  transition: background 0.15s;
+  border-radius: 6px;
+}
+
+.endpoint-row:hover {
+  background: rgba(128, 128, 128, 0.03);
+}
+
+.endpoint-row + .endpoint-row {
+  border-top: 1px solid rgba(128, 128, 128, 0.1);
+}
+
+.endpoint-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.method-tag {
+  font-weight: 700;
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 11px;
+  letter-spacing: 0.5px;
+  min-width: 56px;
+  text-align: center;
+  text-transform: uppercase;
+  border-radius: 4px;
+  padding: 2px 8px;
+  line-height: 1.6;
+}
+
+.endpoint-path {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+  font-size: 13.5px;
+  word-break: break-all;
+  color: rgba(0, 0, 0, 0.8);
+  background: rgba(128, 128, 128, 0.06);
+  padding: 2px 8px;
+  border-radius: 4px;
+}
+
+.endpoint-summary {
+  margin: 8px 0 0;
+  color: rgba(0, 0, 0, 0.6);
+  line-height: 1.6;
+  font-size: 13.5px;
+}
+
+.endpoint-block {
+  margin-top: 14px;
+}
+
+.block-label {
+  font-size: 11px;
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.6px;
+  color: rgba(0, 0, 0, 0.45);
+  margin-bottom: 6px;
+}
+
+.error-label {
+  color: #cf222e;
+}
+
+body.dark .endpoint-row:hover {
+  background: rgba(255, 255, 255, 0.02);
+}
+
+body.dark .endpoint-row + .endpoint-row {
+  border-top-color: rgba(255, 255, 255, 0.08);
+}
+
+body.dark .endpoint-path {
+  color: rgba(255, 255, 255, 0.82);
+  background: rgba(255, 255, 255, 0.05);
+}
+
+body.dark .endpoint-summary {
+  color: rgba(255, 255, 255, 0.65);
+}
+
+body.dark .block-label {
+  color: rgba(255, 255, 255, 0.45);
+}
+
+body.dark .error-label {
+  color: #ff7b72;
+}

+ 84 - 0
frontend/src/pages/api-docs/EndpointRow.tsx

@@ -0,0 +1,84 @@
+import { Table, Tag } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { methodColors, safeInlineHtml } from './endpoints.js';
+import CodeBlock from './CodeBlock';
+import './EndpointRow.css';
+
+interface Param {
+  name: string;
+  in?: string;
+  type?: string;
+  desc?: string;
+}
+
+export interface Endpoint {
+  method: string;
+  path: string;
+  summary?: string;
+  params?: Param[];
+  body?: string;
+  response?: string;
+  errorResponse?: string;
+}
+
+const paramColumns: ColumnsType<Param> = [
+  { title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
+  { title: 'In', dataIndex: 'in', key: 'in', width: 100 },
+  { title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
+  { title: 'Description', dataIndex: 'desc', key: 'desc' },
+];
+
+export default function EndpointRow({ endpoint }: { endpoint: Endpoint }) {
+  const tagColor = (methodColors as Record<string, string>)[endpoint.method] || 'default';
+  const hasParams = Array.isArray(endpoint.params) && endpoint.params.length > 0;
+
+  return (
+    <div className="endpoint-row">
+      <div className="endpoint-header">
+        <Tag color={tagColor} className="method-tag">{endpoint.method}</Tag>
+        <code className="endpoint-path">{endpoint.path}</code>
+      </div>
+
+      {endpoint.summary && (
+        <p
+          className="endpoint-summary"
+          dangerouslySetInnerHTML={{ __html: safeInlineHtml(endpoint.summary) }}
+        />
+      )}
+
+      {hasParams && (
+        <div className="endpoint-block">
+          <div className="block-label">Parameters</div>
+          <Table
+            columns={paramColumns}
+            dataSource={endpoint.params}
+            pagination={false}
+            size="small"
+            rowKey="name"
+          />
+        </div>
+      )}
+
+      {endpoint.body && (
+        <div className="endpoint-block">
+          <div className="block-label">Request body</div>
+          <CodeBlock code={endpoint.body} lang="json" />
+        </div>
+      )}
+
+      {endpoint.response && (
+        <div className="endpoint-block">
+          <div className="block-label">Response</div>
+          <CodeBlock code={endpoint.response} lang="json" />
+        </div>
+      )}
+
+      {endpoint.errorResponse && (
+        <div className="endpoint-block">
+          <div className="block-label error-label">Error response</div>
+          <CodeBlock code={endpoint.errorResponse} lang="json" />
+        </div>
+      )}
+    </div>
+  );
+}

+ 0 - 172
frontend/src/pages/api-docs/EndpointRow.vue

@@ -1,172 +0,0 @@
-<script setup>
-import { computed } from 'vue';
-import { methodColors, safeInlineHtml } from './endpoints.js';
-import CodeBlock from './CodeBlock.vue';
-
-const props = defineProps({
-  endpoint: { type: Object, required: true },
-});
-
-const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
-const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
-
-const paramColumns = [
-  { title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
-  { title: 'In', dataIndex: 'in', key: 'in', width: 100 },
-  { title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
-  { title: 'Description', dataIndex: 'desc', key: 'desc' },
-];
-</script>
-
-<template>
-  <div class="endpoint-row">
-    <div class="endpoint-header">
-      <a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
-      <code class="endpoint-path">{{ endpoint.path }}</code>
-    </div>
-
-    <p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
-
-    <div v-if="hasParams" class="endpoint-block">
-      <div class="block-label">Parameters</div>
-      <a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
-    </div>
-
-    <div v-if="endpoint.body" class="endpoint-block">
-      <div class="block-label">Request body</div>
-      <CodeBlock :code="endpoint.body" lang="json" />
-    </div>
-
-    <div v-if="endpoint.response" class="endpoint-block">
-      <div class="block-label">Response</div>
-      <CodeBlock :code="endpoint.response" lang="json" />
-    </div>
-
-    <div v-if="endpoint.errorResponse" class="endpoint-block">
-      <div class="block-label error-label">Error response</div>
-      <CodeBlock :code="endpoint.errorResponse" lang="json" />
-    </div>
-  </div>
-</template>
-
-<style scoped>
-.endpoint-row {
-  padding: 14px 0;
-  margin: 0;
-  transition: background 0.15s;
-  border-radius: 6px;
-  padding-left: 8px;
-  padding-right: 8px;
-  margin-left: -8px;
-  margin-right: -8px;
-}
-
-.endpoint-row:hover {
-  background: rgba(128, 128, 128, 0.03);
-}
-
-.endpoint-row + .endpoint-row {
-  border-top: 1px solid rgba(128, 128, 128, 0.1);
-}
-
-.endpoint-header {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  flex-wrap: wrap;
-}
-
-.method-tag {
-  font-weight: 700;
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 11px;
-  letter-spacing: 0.5px;
-  min-width: 56px;
-  text-align: center;
-  text-transform: uppercase;
-  border-radius: 4px;
-  padding: 2px 8px;
-  line-height: 1.6;
-}
-
-.endpoint-path {
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 13.5px;
-  word-break: break-all;
-  color: rgba(0, 0, 0, 0.8);
-  background: rgba(128, 128, 128, 0.06);
-  padding: 2px 8px;
-  border-radius: 4px;
-}
-
-.endpoint-summary {
-  margin: 8px 0 0;
-  color: rgba(0, 0, 0, 0.6);
-  line-height: 1.6;
-  font-size: 13.5px;
-}
-
-.endpoint-block {
-  margin-top: 14px;
-}
-
-.block-label {
-  font-size: 11px;
-  font-weight: 600;
-  text-transform: uppercase;
-  letter-spacing: 0.6px;
-  color: rgba(0, 0, 0, 0.45);
-  margin-bottom: 6px;
-}
-
-.error-label {
-  color: #cf222e;
-}
-
-.code-block {
-  background: rgba(128, 128, 128, 0.08);
-  border: 1px solid rgba(128, 128, 128, 0.15);
-  border-radius: 6px;
-  padding: 10px 12px;
-  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
-  font-size: 12.5px;
-  line-height: 1.55;
-  margin: 0;
-  white-space: pre-wrap;
-  word-break: break-word;
-  overflow-x: auto;
-}
-</style>
-
-<style>
-body.dark .endpoint-row:hover {
-  background: rgba(255, 255, 255, 0.02);
-}
-
-body.dark .endpoint-row + .endpoint-row {
-  border-top-color: rgba(255, 255, 255, 0.08);
-}
-
-body.dark .endpoint-path {
-  color: rgba(255, 255, 255, 0.82);
-  background: rgba(255, 255, 255, 0.05);
-}
-
-body.dark .endpoint-summary {
-  color: rgba(255, 255, 255, 0.65);
-}
-
-body.dark .block-label {
-  color: rgba(255, 255, 255, 0.45);
-}
-
-body.dark .error-label {
-  color: #ff7b72;
-}
-
-body.dark .code-block {
-  background: rgba(255, 255, 255, 0.04);
-  border-color: rgba(255, 255, 255, 0.1);
-  color: rgba(255, 255, 255, 0.88);
-}
-</style>

+ 2 - 65
frontend/src/pages/api-docs/EndpointSection.vue → frontend/src/pages/api-docs/EndpointSection.css

@@ -1,63 +1,3 @@
-<script setup>
-import { computed } from 'vue';
-import {
-  DownOutlined,
-  RightOutlined,
-} from '@ant-design/icons-vue';
-import EndpointRow from './EndpointRow.vue';
-import { safeInlineHtml } from './endpoints.js';
-
-const props = defineProps({
-  section: { type: Object, required: true },
-  icon: { type: [Object, Function], default: null },
-  collapsed: { type: Boolean, default: false },
-});
-
-const emit = defineEmits(['toggle']);
-
-const endpointLabel = computed(() =>
-  props.section.endpoints.length === 1
-    ? '1 endpoint'
-    : `${props.section.endpoints.length} endpoints`
-);
-</script>
-
-<template>
-  <section :id="section.id" class="api-section">
-    <div class="section-header" @click="emit('toggle')">
-      <div class="section-header-left">
-        <DownOutlined v-if="!collapsed" class="collapse-icon" />
-        <RightOutlined v-else class="collapse-icon" />
-        <component v-if="icon" :is="icon" class="section-icon" />
-        <h2 class="section-title">{{ section.title }}</h2>
-      </div>
-      <span class="endpoint-count">{{ endpointLabel }}</span>
-    </div>
-    <p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
-
-    <div v-if="section.subHeader && !collapsed" class="sub-header-block">
-      <div class="block-label">Response headers</div>
-      <a-table
-        :columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
-        :data-source="section.subHeader"
-        :pagination="false"
-        size="small"
-        row-key="name"
-      >
-        <template #bodyCell="{ column, text }">
-          <span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
-          <template v-else>{{ text }}</template>
-        </template>
-      </a-table>
-    </div>
-
-    <div v-show="!collapsed" class="endpoints">
-      <EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
-    </div>
-  </section>
-</template>
-
-<style scoped>
 .api-section {
   background: #fff;
   border: 1px solid rgba(128, 128, 128, 0.12);
@@ -131,7 +71,7 @@ const endpointLabel = computed(() =>
   margin-bottom: 14px;
 }
 
-.block-label {
+.section-block-label {
   font-size: 12px;
   font-weight: 600;
   text-transform: uppercase;
@@ -148,9 +88,7 @@ const endpointLabel = computed(() =>
 .endpoints > :first-child {
   padding-top: 0;
 }
-</style>
 
-<style>
 body.dark .api-section {
   background: #252526;
   border-color: rgba(255, 255, 255, 0.08);
@@ -181,7 +119,7 @@ body.dark .section-description {
   color: rgba(255, 255, 255, 0.7);
 }
 
-body.dark .block-label {
+body.dark .section-block-label {
   color: rgba(255, 255, 255, 0.55);
 }
 
@@ -189,4 +127,3 @@ body.dark .endpoint-count {
   color: rgba(255, 255, 255, 0.55);
   background: rgba(255, 255, 255, 0.06);
 }
-</style>

+ 90 - 0
frontend/src/pages/api-docs/EndpointSection.tsx

@@ -0,0 +1,90 @@
+import type { ComponentType } from 'react';
+import { Table } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { DownOutlined, RightOutlined } from '@ant-design/icons';
+import EndpointRow from './EndpointRow';
+import type { Endpoint } from './EndpointRow';
+import { safeInlineHtml } from './endpoints.js';
+import './EndpointSection.css';
+
+interface SubHeader {
+  name: string;
+  desc?: string;
+}
+
+export interface Section {
+  id: string;
+  title: string;
+  description?: string;
+  endpoints: Endpoint[];
+  subHeader?: SubHeader[];
+}
+
+interface EndpointSectionProps {
+  section: Section;
+  icon?: ComponentType<{ className?: string }> | null;
+  collapsed?: boolean;
+  onToggle?: () => void;
+}
+
+const subHeaderColumns: ColumnsType<SubHeader> = [
+  { title: 'Header', dataIndex: 'name', key: 'name', width: 240 },
+  {
+    title: 'Description',
+    dataIndex: 'desc',
+    key: 'desc',
+    render: (value: string) => (
+      <span dangerouslySetInnerHTML={{ __html: safeInlineHtml(value || '') }} />
+    ),
+  },
+];
+
+export default function EndpointSection({
+  section,
+  icon: Icon = null,
+  collapsed = false,
+  onToggle,
+}: EndpointSectionProps) {
+  const endpointLabel = section.endpoints.length === 1
+    ? '1 endpoint'
+    : `${section.endpoints.length} endpoints`;
+
+  return (
+    <section id={section.id} className="api-section">
+      <div className="section-header" onClick={onToggle}>
+        <div className="section-header-left">
+          {collapsed ? <RightOutlined className="collapse-icon" /> : <DownOutlined className="collapse-icon" />}
+          {Icon && <Icon className="section-icon" />}
+          <h2 className="section-title">{section.title}</h2>
+        </div>
+        <span className="endpoint-count">{endpointLabel}</span>
+      </div>
+
+      {section.description && !collapsed && (
+        <p
+          className="section-description"
+          dangerouslySetInnerHTML={{ __html: safeInlineHtml(section.description) }}
+        />
+      )}
+
+      {section.subHeader && !collapsed && (
+        <div className="sub-header-block">
+          <div className="section-block-label">Response headers</div>
+          <Table
+            columns={subHeaderColumns}
+            dataSource={section.subHeader}
+            pagination={false}
+            size="small"
+            rowKey="name"
+          />
+        </div>
+      )}
+
+      <div className="endpoints" style={{ display: collapsed ? 'none' : undefined }}>
+        {section.endpoints.map((endpoint, idx) => (
+          <EndpointRow key={idx} endpoint={endpoint} />
+        ))}
+      </div>
+    </section>
+  );
+}

+ 5 - 0
frontend/src/pages/clients/ClientBulkAddModal.css

@@ -0,0 +1,5 @@
+.random-icon {
+  margin-left: 4px;
+  cursor: pointer;
+  color: var(--ant-color-primary, #1677ff);
+}

+ 341 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -0,0 +1,341 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
+import { SyncOutlined } from '@ant-design/icons';
+import dayjs from 'dayjs';
+import type { Dayjs } from 'dayjs';
+
+import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
+import { TLS_FLOW_CONTROL } from '@/models/inbound';
+import DateTimePicker from '@/components/DateTimePicker';
+import type { InboundOption } from '@/hooks/useClients';
+import './ClientBulkAddModal.css';
+
+const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
+const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
+
+const MULTI_CLIENT_PROTOCOLS = new Set([
+  'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'hysteria2',
+]);
+
+interface ApiMsg {
+  success?: boolean;
+  msg?: string;
+}
+
+interface ClientBulkAddModalProps {
+  open: boolean;
+  inbounds: InboundOption[];
+  ipLimitEnable?: boolean;
+  onOpenChange: (open: boolean) => void;
+  onSaved?: () => void;
+}
+
+interface FormState {
+  emailMethod: number;
+  firstNum: number;
+  lastNum: number;
+  emailPrefix: string;
+  emailPostfix: string;
+  quantity: number;
+  subId: string;
+  comment: string;
+  flow: string;
+  limitIp: number;
+  totalGB: number;
+  expiryTime: number;
+  inboundIds: number[];
+}
+
+function emptyForm(): FormState {
+  return {
+    emailMethod: 0,
+    firstNum: 1,
+    lastNum: 1,
+    emailPrefix: '',
+    emailPostfix: '',
+    quantity: 1,
+    subId: '',
+    comment: '',
+    flow: '',
+    limitIp: 0,
+    totalGB: 0,
+    expiryTime: 0,
+    inboundIds: [],
+  };
+}
+
+export default function ClientBulkAddModal({
+  open,
+  inbounds,
+  ipLimitEnable = false,
+  onOpenChange,
+  onSaved,
+}: ClientBulkAddModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+
+  const [form, setForm] = useState<FormState>(emptyForm);
+  const [delayedStart, setDelayedStart] = useState(false);
+  const [saving, setSaving] = useState(false);
+
+  useEffect(() => {
+    if (!open) return;
+     
+    setForm(emptyForm());
+    setDelayedStart(false);
+     
+  }, [open]);
+
+  function update<K extends keyof FormState>(key: K, value: FormState[K]) {
+    setForm((prev) => ({ ...prev, [key]: value }));
+  }
+
+  const flowCapableIds = useMemo(() => {
+    const ids = new Set<number>();
+    for (const row of inbounds || []) {
+      if (row?.tlsFlowCapable) ids.add(row.id);
+    }
+    return ids;
+  }, [inbounds]);
+
+  const showFlow = useMemo(
+    () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
+    [form.inboundIds, flowCapableIds],
+  );
+
+  useEffect(() => {
+    if (!showFlow && form.flow) {
+       
+      update('flow', '');
+    }
+  }, [showFlow, form.flow]);
+
+  const inboundOptions = useMemo(
+    () => (inbounds || [])
+      .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
+      .map((ib) => ({
+        label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+        value: ib.id,
+      })),
+    [inbounds],
+  );
+
+  const expiryDate = useMemo<Dayjs | null>(
+    () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
+    [form.expiryTime],
+  );
+
+  const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
+
+  function buildEmails(): string[] {
+    const method = form.emailMethod;
+    const out: string[] = [];
+    let start: number;
+    let end: number;
+    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++) {
+      let email = '';
+      if (method !== 4) email = RandomUtil.randomLowerAndNum(6);
+      email += useNum ? prefix + String(i) + postfix : prefix + postfix;
+      out.push(email);
+    }
+    return out;
+  }
+
+  async function submit() {
+    if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
+      messageApi.error(t('pages.clients.selectInbound'));
+      return;
+    }
+    const emails = buildEmails();
+    if (emails.length === 0) return;
+
+    setSaving(true);
+    const silentJsonOpts = { ...JSON_HEADERS, silent: true };
+    try {
+      const results = await Promise.all(emails.map((email) => {
+        const client = {
+          email,
+          subId: form.subId || RandomUtil.randomLowerAndNum(16),
+          id: RandomUtil.randomUUID(),
+          password: RandomUtil.randomLowerAndNum(16),
+          auth: RandomUtil.randomLowerAndNum(16),
+          flow: showFlow ? (form.flow || '') : '',
+          totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
+          expiryTime: form.expiryTime,
+          limitIp: Number(form.limitIp) || 0,
+          comment: form.comment,
+          enable: true,
+        };
+        const payload = { client, inboundIds: form.inboundIds };
+        return HttpUtil.post('/panel/api/clients/add', payload, silentJsonOpts) as Promise<ApiMsg>;
+      }));
+      let ok = 0;
+      let failed = 0;
+      let firstError = '';
+      for (const msg of results) {
+        if (msg?.success) ok++;
+        else {
+          failed++;
+          if (!firstError && msg?.msg) firstError = msg.msg;
+        }
+      }
+      if (failed === 0) {
+        messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
+      } else {
+        messageApi.warning(firstError
+          ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
+          : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
+      }
+      onSaved?.();
+      onOpenChange(false);
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={t('pages.clients.bulk')}
+        okText={t('create')}
+      cancelText={t('close')}
+      confirmLoading={saving}
+      mask={{ closable: false }}
+      width={640}
+      onOk={submit}
+      onCancel={() => onOpenChange(false)}
+    >
+      <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
+        <Form.Item label={t('pages.clients.attachedInbounds')} required>
+          <Select
+            mode="multiple"
+            value={form.inboundIds}
+            onChange={(v) => update('inboundIds', v)}
+            options={inboundOptions}
+            placeholder={t('pages.clients.selectInbound')}
+            showSearch
+            filterOption={(input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())}
+          />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.method')}>
+          <Select
+            value={form.emailMethod}
+            onChange={(v) => update('emailMethod', v)}
+            options={[
+              { value: 0, label: 'Random' },
+              { value: 1, label: 'Random + Prefix' },
+              { value: 2, label: 'Random + Prefix + Num' },
+              { value: 3, label: 'Random + Prefix + Num + Postfix' },
+              { value: 4, label: 'Prefix + Num + Postfix' },
+            ]}
+          />
+        </Form.Item>
+
+        {form.emailMethod > 1 && (
+          <>
+            <Form.Item label={t('pages.clients.first')}>
+              <InputNumber value={form.firstNum} min={1} onChange={(v) => update('firstNum', Number(v) || 1)} />
+            </Form.Item>
+            <Form.Item label={t('pages.clients.last')}>
+              <InputNumber value={form.lastNum} min={form.firstNum} onChange={(v) => update('lastNum', Number(v) || 1)} />
+            </Form.Item>
+          </>
+        )}
+        {form.emailMethod > 0 && (
+          <Form.Item label={t('pages.clients.prefix')}>
+            <Input value={form.emailPrefix} onChange={(e) => update('emailPrefix', e.target.value)} />
+          </Form.Item>
+        )}
+        {form.emailMethod > 2 && (
+          <Form.Item label={t('pages.clients.postfix')}>
+            <Input value={form.emailPostfix} onChange={(e) => update('emailPostfix', e.target.value)} />
+          </Form.Item>
+        )}
+        {form.emailMethod < 2 && (
+          <Form.Item label={t('pages.clients.clientCount')}>
+            <InputNumber value={form.quantity} min={1} max={100} onChange={(v) => update('quantity', Number(v) || 1)} />
+          </Form.Item>
+        )}
+
+        <Form.Item label={
+          <>
+            {t('subscription.title')}
+            <SyncOutlined
+              className="random-icon"
+              onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
+            />
+          </>
+        }>
+          <Input value={form.subId} onChange={(e) => update('subId', e.target.value)} />
+        </Form.Item>
+
+        <Form.Item label={t('comment')}>
+          <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
+        </Form.Item>
+
+        {showFlow && (
+          <Form.Item label={t('pages.clients.flow')}>
+            <Select
+              value={form.flow}
+              onChange={(v) => update('flow', v)}
+              style={{ width: 220 }}
+              options={[
+                { value: '', label: t('none') },
+                ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+              ]}
+            />
+          </Form.Item>
+        )}
+
+        {ipLimitEnable && (
+          <Form.Item label={t('pages.clients.limitIp')}>
+            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+          </Form.Item>
+        )}
+
+        <Form.Item label={t('pages.clients.totalGB')}>
+          <InputNumber value={form.totalGB} min={0} step={0.1} onChange={(v) => update('totalGB', Number(v) || 0)} />
+        </Form.Item>
+
+        <Form.Item label={t('pages.clients.delayedStart')}>
+          <Switch
+            checked={delayedStart}
+            onClick={() => { setDelayedStart(!delayedStart); update('expiryTime', 0); }}
+          />
+        </Form.Item>
+
+        {delayedStart ? (
+          <Form.Item label={t('pages.clients.expireDays')}>
+            <InputNumber
+              value={delayedExpireDays}
+              min={0}
+              onChange={(v) => update('expiryTime', -86400000 * (Number(v) || 0))}
+            />
+          </Form.Item>
+        ) : (
+          <Form.Item label={t('pages.inbounds.expireDate')}>
+            <DateTimePicker
+              value={expiryDate}
+              onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
+            />
+          </Form.Item>
+        )}
+      </Form>
+      </Modal>
+    </>
+  );
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است