Bläddra i källkod

Frontend rewrite: React + TypeScript with AntD v6 (#4498)

* chore(frontend): add react+typescript toolchain alongside vue

Step 0 of the planned vue->react migration. React 19, antd 5, i18next
+ react-i18next, typescript 5, and @vitejs/plugin-react 6 are added as
dev/runtime deps alongside the existing vue stack. Both frameworks
coexist in the build until the last entry flips.

* vite.config.js: react() plugin runs next to vue(); new manualChunks
  for vendor-react / vendor-antd-react / vendor-icons-react /
  vendor-i18next. Existing vue chunks unchanged.
* eslint.config.js: typescript-eslint + eslint-plugin-react-hooks
  rules scoped to *.{ts,tsx}; vue config untouched for *.{js,vue}.
* tsconfig.json: strict, jsx: react-jsx, moduleResolution: bundler,
  allowJs: true (lets .tsx files import the remaining .js modules
  during incremental migration), @/* path alias.
* env.d.ts: Vite client types + window.X_UI_BASE_PATH typing +
  SubPageData shape consumed by the subscription page.

Vite stays pinned at 8.0.13 per the existing project policy. No
existing .vue/.js source files touched in this step.

eslint-plugin-react (not -hooks) is not included because its latest
release does not yet support ESLint 10. react-hooks/purity covers
the safety-critical case; revisit when the plugin updates.

* refactor(frontend): port subpage to react+ts

Step 1 of the planned vue->react migration. The standalone
subscription page (sub/sub.go renders the HTML host; React mounts
into #app) is the first entry off vue.

Introduces two shared pieces both entries (and future ones) will
use:

* src/hooks/useTheme.tsx — React Context + useTheme hook + the
  same buildAntdThemeConfig (dark/ultra-dark token overrides) and
  pauseAnimationsUntilLeave helper the vue version exposes. Same
  localStorage keys (dark-mode, isUltraDarkThemeEnabled) and DOM
  side effects (body.className, html[data-theme]) so the two stay
  in sync across the coexistence period.
* src/i18n/react.ts — i18next + react-i18next loader that reads
  the same web/translation/*.json files via import.meta.glob. The
  vue-i18n setup in src/i18n/index.js is untouched and still serves
  the remaining vue entries.

SubPage.tsx mirrors the vue version's behavior: reads
window.__SUB_PAGE_DATA__ injected by the Go sub server, renders QR
codes / descriptions / Android+iOS deep-link dropdowns, supports
theme cycle and language switch. Uses AntD v5 idioms: Descriptions
items prop, Dropdown menu prop, Layout.Content.

* refactor(frontend): port login to react+ts

Step 2 of the planned vue->react migration. The login entry is the
first to exercise AntD React's Form API (Form + Form.Item with
name/rules + onFinish) and the existing axios/CSRF interceptors
under React.

* LoginPage.tsx: same form fields, conditional 2FA input,
  rotating headline ("Hello" / "Welcome to..."), drifting blob
  background, theme cycle + language popover. Headline transition
  switches from vue's <Transition mode=out-in> to a CSS keyframe
  animation keyed off the visible word.
* entries/login.tsx: setupAxios() + applyDocumentTitle() unchanged
  from the vue entry — both are framework-agnostic in src/utils
  and src/api/axios-init.js.

useTheme hook, ThemeProvider, and i18n/react.ts loader introduced
in step 1 are now shared across two entries; Vite extracts them as
a small chunk in the build output.

* refactor(frontend): port api-docs to react+ts

Step 3 of the planned vue->react migration. The five api-docs files
(ApiDocsPage, CodeBlock, EndpointRow, EndpointSection, plus the
data-only endpoints.js) all move to react+ts.

Also introduces components/AppSidebar.tsx — api-docs is the first
authenticated page to need it. AppSidebar.vue stays in place for the
six remaining vue entries (settings, inbounds, clients, xray, nodes,
index); each gets switched to AppSidebar.tsx as its entry migrates.
After the last entry flips, AppSidebar.vue is deleted.

Notable transformations:

* The scroll observer that highlights the active TOC link is a
  useEffect keyed on sections — re-registers whenever the visible
  set changes (search filter narrows it). Same behaviour as the vue
  watchEffect.
* v-html="safeInlineHtml(...)" becomes
  dangerouslySetInnerHTML={{ __html: safeInlineHtml(...) }}. The
  helper still escapes everything except <code> tags.
* JSON syntax highlighter in CodeBlock is unchanged — pure regex on
  the escaped string, then rendered via dangerouslySetInnerHTML.
* endpoints.js stays as JS (allowJs in tsconfig); only the consumer
  signatures (Endpoint, Section) are typed at the React boundary.
* AppSidebar reuses pauseAnimationsUntilLeave + useTheme from
  step 1. Drawer + Sider keyed off the same localStorage flag
  (isSidebarCollapsed) and DOM theme attributes the vue version
  uses, so the two stay in sync during coexistence.

* refactor(frontend): port nodes to react+ts

Step 4 of the planned vue->react migration. The nodes entry brings in
the largest shared-infrastructure batch so far — every authenticated
react page from here on can lean on these.

New shared pieces (live alongside their .vue counterparts during
coexistence):

* hooks/useMediaQuery.ts — useState + resize listener
* hooks/useWebSocket.ts — wraps WebSocketClient, subscribes on mount
  and unsubscribes on unmount. The underlying client is a single
  module-level instance so multiple components on the same page
  share one socket.
* hooks/useNodes.ts — node list state + CRUD + probe/test, including
  the totals memo (online/offline/avgLatency) used by the summary card.
  applyNodesEvent is the entry point for the heartbeat-pushed list.
* components/CustomStatistic.tsx — thin Statistic wrapper, prefix +
  suffix slots become props.
* components/Sparkline.tsx — the SVG line chart with measured-width
  axis scaling, gradient fill, tooltip overlay, and per-instance
  gradient id from React.useId. ResizeObserver lifecycle is in
  useEffect; the math is unchanged.

Pages:

* NodesPage — wires hooks + WebSocket together, renders summary card
  + NodeList, hosts the form modal. Uses Modal.useModal() for the
  delete confirm so the dialog inherits ConfigProvider theming.
* NodeList — desktop renders a Table with expandable history rows;
  mobile flips to a vertical card list whose actions live in a
  bottom-right Dropdown. The IP-blur eye toggle persists across both.
* NodeFormModal — controlled form (useState object, single setForm
  per change). The reset-on-open effect computes the next state
  once and applies it with eslint-disable to satisfy the new
  react-hooks/set-state-in-effect rule on a legitimate pattern.
* NodeHistoryPanel — polls /panel/api/nodes/history/{id}/{metric}/
  {bucket} every 15s, renders cpu+mem sparklines side-by-side.

* refactor(frontend): port settings to react+ts

Step 5 of the planned vue->react migration. Settings is the first
entry whose state model didn't translate to the Vue-style "parent
passes a reactive object, children mutate it in place" pattern, so
the React port flips it to lifted state + a typed updateSetting
patch function.

* models/setting.ts — typed AllSetting class with the same field
  defaults and equals() behavior the vue version had. The .js
  twin is deleted; nothing else imported it.
* hooks/useAllSetting.ts — owns allSetting + oldAllSetting state,
  exposes updateSetting(patch), saveDisabled is derived via useMemo
  off equals() (no more 1Hz dirty-check timer).
* components/SettingListItem.tsx — children-based wrapper instead
  of named slots. The vue twin stays alive because xray (BasicsTab,
  DnsTab) still imports it; deleted when xray migrates.

The five tab components and the TwoFactorModal each accept
{ allSetting, updateSetting } and render with AntD v5's Collapse
items[] API. Every v-model:value="x" became
value={...} onChange={(e) => updateSetting({ key: e.target.value })}
or onChange={(v) => updateSetting({ key: v })} for non-input
controls.

SubscriptionFormatsTab is the trickiest — fragment / noises[] /
mux / direct routing rules are stored as JSON-encoded strings on
the wire. Parsing them once via useMemo per field, mutating the
parsed object on edit, and stringifying back into the patch keeps
the round-trip identical to the vue version.

SettingsPage hosts the tab navigation (with hash sync), the
save / restart action bar, the security-warnings alert banner,
and the restart flow that rebuilds the panel URL after the new
host/port/cert settings take effect.

* refactor(frontend): port clients to react+ts

Step 6 of the planned vue->react migration. Clients is the biggest
data-CRUD page in the panel (1.1k-line ClientsPage, 4 modals, full
table + mobile card list, WebSocket-driven realtime traffic + online
updates).

New shared infra (lives alongside vue twins until inbounds migrates):

* hooks/useClients.ts — clients + inbounds list, CRUD + bulk delete +
  attach/detach + traffic reset, with WebSocket event handlers
  (traffic, client_stats, invalidate) and a small debounced refresh
  on the invalidate event. State managed via setState; the live
  client_stats event merges traffic snapshots row-by-row through a
  ref to avoid stale closure issues.
* hooks/useDatepicker.ts — singleton "gregorian"/"jalalian" cache
  with subscribe/notify so multiple components can read the panel's
  Calendar Type without re-fetching. Mirrors useDatepicker.js.
* components/DateTimePicker.tsx — AntD DatePicker wrapper.
  vue3-persian-datetime-picker has no React port; the Jalali UI
  calendar is deferred (read-only Jalali display via IntlUtil
  formatDate still works). The vue twin stays for inbounds.
* pages/inbounds/QrPanel.tsx — copy/download/copy-as-png QR helper
  shared between clients (qr modal) and inbounds (still on vue).
  Vue twin stays alive at QrPanel.vue.
* models/inbound.ts — slim port: only the TLS_FLOW_CONTROL constant
  the clients form needs. The full inbound model stays as
  inbound.js for now; inbounds will pull it in as inbound.ts.

The clients page itself uses Modal.useModal() for all confirm
dialogs (delete, bulk-delete, reset-traffic, delDepleted, reset-all)
so the dialogs render themed. Filter state persists to
localStorage under clientsFilterState. Sort + pagination state is
local; pageSize seeds from /panel/setting/defaultSettings.

The four modals share a controlled "open/onOpenChange" pattern
that replaces vue's v-model:open. ClientFormModal computes
attach/detach diffs from the inbound multi-select on submit; the
parent's onSave callback routes them through useClients's attach()/
detach() after the main update succeeds.

ESLint config: turned off four react-hooks v7 rules
(react-compiler, preserve-manual-memoization, set-state-in-effect,
purity). They're all React-Compiler-driven informational rules; we
don't run the compiler and the patterns they flag (initial-fetch
useEffect, derived computations using Date.now, inline arrow event
handlers) are all idiomatic React. Disabling globally instead of
per-line keeps the diff readable.

* refactor(frontend): port index dashboard to react+ts

Step 7 of the Vue→React migration. Ports the overview/index entry: dashboard
page, status + xray cards, panel-update / log / backup / system-history /
xray-metrics / xray-log / version modals, and the custom-geo subsection. Adds
the shared JsonEditor (CodeMirror 6) and useStatus hook used by the config
modal. Removes the unused react-hooks/set-state-in-effect disables now that
the rule is off globally.

* refactor(frontend): port xray to react+ts

Step 8 of the Vue→React migration. Ports the xray config entry: page shell,
basics/routing/outbounds/balancers/dns tabs, the rule + balancer + dns server
+ dns presets + warp + nord modals, the protocol-aware outbound form, and the
shared FinalMaskForm (TCP/UDP masks + QUIC params). Adds useXraySetting that
mirrors the legacy two-way sync between the JSON template string and the
parsed templateSettings tree. The outbound model itself stays in JS so the
class-driven form keeps its existing mutation API; instance access is typed
loosely inside the form to match.

The shared FinalMaskForm.vue and JsonEditor.vue stay alongside the new .tsx
versions until step 9 — InboundFormModal.vue still imports them.

Adds react-hooks/immutability and react-hooks/refs to the already-disabled
react-compiler rule set; both flag the outbound form's instance-mutation
pattern that doesn't run through useState.

* Upgrade frontend deps (antd v6, i18n, TS)

Bump frontend dependencies in package.json and regenerate package-lock.json. Notable updates: upgrade antd to v6, update i18next/react-i18next, axios, qs, vue-i18n, TypeScript and ESLint, plus related @rc-component packages and replacements (e.g. classnames/rc-util -> clsx/@rc-component/util). Lockfile changes reflect the new dependency tree required for Ant Design v6 and other package upgrades.

* refactor(frontend): port inbounds to react+ts and drop vue toolchain

Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.

Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.

Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.

* chore(frontend): adopt antd v6 api updates

Sweep deprecated props across the React tree:
- Modal: destroyOnClose -> destroyOnHidden, maskClosable -> mask.closable
- Space: direction -> orientation (or removed when redundant)
- Input.Group compact -> Space.Compact block
- Drawer: width -> size
- Spin: tip -> description
- Progress: trailColor -> railColor
- Alert: message -> title
- Popover: overlayClassName -> rootClassName
- BackTop -> FloatButton.BackTop

Also refresh dashboard theming for v6: rename dark/ultra Layout and Menu
tokens (siderBg, darkItemBg, darkSubMenuItemBg, darkPopupBg), tweak gauge
size/stroke, add font-size overrides for Statistic and Progress so the
overview numbers stay legible under v6 defaults.

* chore(frontend): antd v6 polish, theme + modal fixes

- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
  inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
  imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
  html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
  actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
  parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
  ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
  to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments

* fix(frontend): qr code collapse — open only first panel, allow toggle

ClientQrModal and QrCodeModal both used activeKey without onChange,
forcing every panel open and blocking user toggle. Switch to controlled
state initialized to the first item's key on open, with onChange so
clicks update state.

Also remove unused AppBridge.tsx (superseded by per-page message.useMessage
hook).

* fix(frontend): hover cards, balancer load, routing dnd, modal a11y, outbound crash

- ClientsPage/SettingsPage/XrayPage: add hoverable to bottom card/tabs so
  hover affordance matches the top card
- BalancerFormModal: lazy-init useState from props + destroyOnHidden so
  the form mounts with saved values instead of relying on a useEffect
  sync that could miss the first open
- RoutingTab: rewrite pointer drag — handlers are now defined inside the
  pointerdown closure so addEventListener/removeEventListener match;
  drag state lives on a ref (from/to/moved) so onUp reads the real
  indices, not stale closure values. Adds setPointerCapture so Windows
  and touch keep delivering events when the cursor leaves the handle.
- OutboundFormModal/InboundFormModal: blur the focused input before
  switching tabs to silence the aria-hidden-on-focused-element warning
- utils.isArrEmpty: return true for undefined/null arrays — the old form
  treated undefined as "not empty" which crashed VLESSSettings.fromJson
  when json.vnext was missing

* fix(frontend): clipboard reliability + restyle login page

- ClipboardManager.copyText: prefer navigator.clipboard on secure
  contexts, fall back to a focused on-screen textarea + execCommand.
  Old path used left:-9999px which failed selection in some browsers
  and swallowed execCommand's return value, so the "copied" toast
  appeared even when nothing made it to the clipboard.
- LoginPage: richer gradient backdrop — five animated colour blobs,
  glassmorphic card (backdrop-filter blur + saturate), gradient brand
  text/accent, masked grid texture for depth, and a thin gradient
  border on the card. Light/dark/ultra each get their own palette.

* Memoize compactAdvancedJson and update deps

Wrap compactAdvancedJson in useCallback (dependent on messageApi) and add it to the dependency array of applyAdvancedJsonToBasic. This ensures a stable function reference for correct dependency tracking and avoids stale closures/unnecessary re-renders in InboundFormModal.tsx.

* style(frontend): prettier charts, drop redundant frame, format net rates

- Sparkline: multi-stop gradient fill, soft drop-shadow under the line,
  dashed grid, glowing pulse on the latest-point marker, pill-shaped
  tooltip with dashed crosshair
- XrayMetricsModal: glow + pulse on the observatory alive dot,
  monospace stamps/listen text
- SystemHistoryModal: keep just the modal's frame around the chart (the
  inner wrapper I'd added stacked a second border on top); strip the
  decimal from Net Up/Down (25.63 KB/s → 25 KB/s) only on this chart's
  formatter

* style(frontend): refined dark/ultra palette + shared pro card frame

- Dark tokens shifted to a cooler, Linear-style palette: page #1a1b1f,
  sidebar/header #15161a (recessed nav, darker than cards), card
  #23252b, elevated #2d2f37
- Ultra dark: page pure #000 for OLED, sidebar #050507 disappears into
  the frame, card #101013 with a clear step, elevated #1a1a1e
- New styles/page-cards.css holds the card border/shadow/hover rules so
  all seven content pages (index, clients, inbounds, xray, settings,
  nodes, api-docs) share one definition instead of duplicating in each
  page CSS
- Dashboard typography: uppercase card titles with letter-spacing,
  larger 17px stat values, subtle gradient divider between stat columns,
  ellipsis on action labels so "Backup & Restore" doesn't break the
  card height at mid widths
- Light --bg-page stays at #e6e8ec for the contrast against white cards

* fix(frontend): wireguard info alignment, blue login dark, embed gitkeep

- align WireGuard info-modal fields with Protocol/Address/Port by wrapping
  values in Tag (matches the rest of the dl.info-list rows)
- swap login dark palette from purple to pure blue blobs/accent/brand
- pin web/dist/.gitkeep through gitignore so //go:embed all:dist never
  fails on a fresh clone with an empty dist directory

* docs: refresh frontend docs for the React + TS + AntD 6 stack

Update CONTRIBUTING.md and frontend/README.md to describe the migrated
frontend accurately:

- replace Vue 3 / Ant Design Vue 4 references with React 19 / AntD 6 / TS
- swap composables -> hooks, vue-i18n -> react-i18next, createApp -> createRoot
- mention the typecheck step (tsc --noEmit) in the PR checklist
- document the Vite 8.0.13 pin and TypeScript strict mode in conventions
- list the nodes and api-docs entries that were missing from the layout

* style(frontend): improve readability and mobile polish

- bump statistic title/value contrast in dark and ultra-dark so totals
  on the inbounds summary card stay legible
- give index card actions explicit colors per theme so links like Stop,
  Logs, System History no longer fade into the card background
- show the panel version as a tag next to "3X-UI" on mobile, mirroring
  the Xray version tag pattern, and turn it orange when an update is
  available
- make the login settings button a proper circle by adding size="large"
  + an explicit border-radius fallback on .toolbar-btn

* feat: jalali calendar support and date formatting fixes

- Wire useDatepicker into IntlUtil and switch jalalian display locale
  to fa-IR for clean "1405/07/03 12:00:00" output (drops the awkward
  "AP" era suffix that "<lang>-u-ca-persian" produced)
- Drop in persian-calendar-suite for the jalali date picker, with a
  light/dark/ultra theme map and CSS overrides so the inline-styled
  input stays readable and bg matches the surrounding container
- Force LTR on the picker input so "1405/03/07 00:00" reads naturally
- Pass calendar setting through ClientInfoModal, ClientsPage Duration
  tooltip, and ClientFormModal's expiry picker
- Heuristic toMs() in ClientInfoModal so GORM's autoUpdateTime seconds
  render as a real date instead of "1348/11/01"
- Persist UpdatedAt on the ClientRecord row in client_service.Update;
  previously only the inbound settings JSON was bumped, so the panel
  never saw a fresh updated_at after editing a client

* feat(frontend): donate link, panel version label, login lang menu

- Sidebar: add heart donate link to https://donate.sanaei.dev and small panel version under 3X-UI brand
- Login: swap settings-cog for translation icon, drop title, render languages as a direct list
- Vite dev: inject window.X_UI_CUR_VER from config/version so dev mode matches prod
- Translations: add menu.donate across all locales

* fix(xray-update): respect XUI_BIN_FOLDER on Windows

The Windows update path hardcoded "bin/xray-windows-amd64.exe", ignoring
the configured XUI_BIN_FOLDER. In dev mode (folder set to x-ui) this
created a stray bin/ folder while the running binary stayed un-updated.

* Bump Xray to v26.5.9 and minor cleanup

Update Xray release URLs to v26.5.9 in the GitHub Actions workflow and DockerInit.sh. Remove the hardcoded skip for tagVersion "26.5.3" so it will be considered when collecting Xray versions. Apply small formatting fixes: remove an extra blank line in database/db.go, normalize spacing/alignment of Protocol constants in database/model/model.go, and trim a trailing blank line in web/controller/inbound.go.

* fix(frontend): route remaining copy buttons through ClipboardManager

Direct navigator.clipboard calls fail in non-secure contexts (HTTP on a
LAN IP), making the API-docs code copy and security-tab token copy
silently broken. Both now go through ClipboardManager which falls back
to document.execCommand('copy') when navigator.clipboard is unavailable.

* fix(db): store CreatedAt/UpdatedAt in milliseconds

GORM's autoCreateTime/autoUpdateTime tags default to Unix seconds on
int64 fields and overwrite the service-supplied UnixMilli value on
save. The frontend interprets these timestamps as JS Date inputs
(milliseconds), so created/updated columns rendered ~1970 dates. Adding
the :milli qualifier makes GORM match what the service code and UI
expect.

* Improve legacy clipboard copy handling

Refactor ClipboardManager._legacyCopy to better handle focus and selection when copying. The textarea is now appended to the active element's parent (or body) and placed off-screen with aria-hidden and readonly attributes. The code preserves and restores the previous document selection and active element, uses focus({preventScroll: true}) to avoid scrolling, and returns the execCommand('copy') result. This makes legacy copy behavior more robust and less disruptive to the page state.

* fix(lint): drop redundant ok=false in clipboard fallback catch

* chore(deps): bump golang.org/x/net to v0.55.0 for GO-2026-5026
Sanaei 1 dag sedan
förälder
incheckning
edf0f36940
100 ändrade filer med 6681 tillägg och 4247 borttagningar
  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"
       - "**.mjs"
       - "**.cjs"
       - "**.cjs"
       - "**.ts"
       - "**.ts"
-      - "**.vue"
       - "**.html"
       - "**.html"
       - "**.css"
       - "**.css"
       - "frontend/package.json"
       - "frontend/package.json"
@@ -27,7 +26,6 @@ on:
       - "**.mjs"
       - "**.mjs"
       - "**.cjs"
       - "**.cjs"
       - "**.ts"
       - "**.ts"
-      - "**.vue"
       - "**.html"
       - "**.html"
       - "**.css"
       - "**.css"
       - "frontend/package.json"
       - "frontend/package.json"

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

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

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

@@ -116,7 +116,7 @@ jobs:
           cd x-ui/bin
           cd x-ui/bin
 
 
           # Download dependencies
           # 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
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -250,7 +250,7 @@ jobs:
           cd x-ui\bin
           cd x-ui\bin
 
 
           # Download Xray for Windows
           # 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"
           Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"
           Remove-Item "Xray-windows-64.zip"

+ 3 - 0
.gitignore

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

+ 67 - 63
CONTRIBUTING.md

@@ -1,33 +1,33 @@
 # Contributing
 # 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
 ## 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**
 - **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
 ### 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)**
 **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
    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\`.
 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:
 4. Open a fresh terminal and confirm:
    ```powershell
    ```powershell
    gcc --version
    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/>.
 1. Install MSYS2 from <https://www.msys2.org/>.
 2. Open the **MSYS2 UCRT64** shell from the Start menu and update once:
 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
    ```bash
    pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-pkg-config
    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.
 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
 ## First-time setup
 
 
@@ -65,7 +65,7 @@ npm run build
 cd ..
 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
 XUI_DEBUG=true
@@ -74,7 +74,7 @@ XUI_LOG_FOLDER=x-ui
 XUI_BIN_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
 ## Running
 
 
@@ -82,11 +82,11 @@ You need to drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-lin
 go run .
 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
 ### 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
 ```jsonc
 {
 {
@@ -113,93 +113,97 @@ The repo ships a launch profile in `.vscode/launch.json` (gitignored — copy fr
 
 
 ## Working on the frontend
 ## 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
 ### 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
 ### 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
 ### 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
 ### 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`.
 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.
 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
 ### 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
 ### Project layout
 
 
 ```
 ```
 frontend/
 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
 ├── vite.config.js
 └── src/
 └── 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
 ## Project layout
 
 
-| Path | What lives there |
-|------|------------------|
+| Path | Contents |
+|------|----------|
 | `main.go` | Process entry point, CLI subcommands, signal handling |
 | `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) |
 | `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) |
 | `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) |
 | `x-ui/` | **Runtime data** — db, logs, xray binary, geo files (gitignored) |
 
 
 ## Sending a pull request
 ## Sending a pull request
 
 
 1. Branch off `main` (e.g. `feat/short-description`).
 1. Branch off `main` (e.g. `feat/short-description`).
 2. Keep the diff focused — separate refactors from feature work.
 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 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.
 5. Open the PR against `main` with a brief description of what changed and how to test it.
 
 
 ## Useful environment variables
 ## 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_LOG_LEVEL` | `info` | `debug` / `info` / `notice` / `warning` / `error` |
 | `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
 | `XUI_DB_FOLDER` | platform default | Where `x-ui.db` lives |
 | `XUI_LOG_FOLDER` | platform default | Where `3xui.log` 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_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
 | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
 | `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)
 - 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)
 - 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
 esac
 mkdir -p build/bin
 mkdir -p build/bin
 cd 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"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"
 mv xray "xray-linux-${FNAME}"

+ 0 - 1
database/db.go

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

+ 18 - 18
database/model/model.go

@@ -16,16 +16,16 @@ type Protocol string
 
 
 // Protocol constants for different Xray inbound protocols
 // Protocol constants for different Xray inbound protocols
 const (
 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".
 // IsHysteria returns true for both "hysteria" and "hysteria2".
@@ -144,7 +144,7 @@ type ApiToken struct {
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
 	Token     string `json:"token" gorm:"not null"`
 	Token     string `json:"token" gorm:"not null"`
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
 	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
 // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON
@@ -275,8 +275,8 @@ type Node struct {
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	OnlineCount   int `json:"onlineCount" gorm:"-"`
 	DepletedCount int `json:"depletedCount" 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 {
 type CustomGeoResource struct {
@@ -287,8 +287,8 @@ type CustomGeoResource struct {
 	LocalPath     string `json:"localPath" gorm:"column:local_path"`
 	LocalPath     string `json:"localPath" gorm:"column:local_path"`
 	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
 	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
 	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
 	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 {
 type ClientReverse struct {
@@ -333,8 +333,8 @@ type ClientRecord struct {
 	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
 	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
 	Comment    string `json:"comment"`
 	Comment    string `json:"comment"`
 	Reset      int    `json:"reset" gorm:"default:0"`
 	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" }
 func (ClientRecord) TableName() string { return "clients" }
@@ -374,7 +374,7 @@ type ClientInbound struct {
 	ClientId     int    `json:"clientId" gorm:"primaryKey;column:client_id;index"`
 	ClientId     int    `json:"clientId" gorm:"primaryKey;column:client_id;index"`
 	InboundId    int    `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
 	InboundId    int    `json:"inboundId" gorm:"primaryKey;column:inbound_id;index"`
 	FlowOverride string `json:"flowOverride" gorm:"column:flow_override"`
 	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" }
 func (ClientInbound) TableName() string { return "client_inbounds" }

+ 22 - 14
frontend/README.md

@@ -1,8 +1,8 @@
 # 3x-ui frontend
 # 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
 ## 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
 `assets/`). The Go binary embeds this directory at compile time and
 `web/controller/dist.go` serves the per-page HTML.
 `web/controller/dist.go` serves the per-page HTML.
 
 
-## Lint
+## Type check and lint
 
 
 ```sh
 ```sh
+npm run typecheck
 npm run lint
 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
 ## Layout
 
 
 ```
 ```
 frontend/
 frontend/
 ├── *.html                 # Vite entry HTML, one per panel route
 ├── *.html                 # Vite entry HTML, one per panel route
+├── tsconfig.json
 ├── eslint.config.js
 ├── eslint.config.js
 ├── vite.config.js
 ├── vite.config.js
 └── src/
 └── src/
-    ├── entries/           # Per-page bootstrap (createApp + mount)
+    ├── entries/           # Per-page bootstrap (createRoot + render)
     ├── pages/             # One folder per route, each with the page
     ├── pages/             # One folder per route, each with the page
     │   ├── index/         # component + helpers + sub-components
     │   ├── index/         # component + helpers + sub-components
     │   ├── login/
     │   ├── login/
     │   ├── inbounds/
     │   ├── inbounds/
+    │   ├── clients/
     │   ├── xray/
     │   ├── xray/
+    │   ├── nodes/
     │   ├── settings/
     │   ├── settings/
+    │   ├── api-docs/
     │   └── sub/
     │   └── 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
     ├── models/            # Inbound, Outbound, Status, … domain classes
+    ├── styles/            # Shared CSS modules (page-cards, …)
     └── utils/             # HttpUtil, ObjectUtil, LanguageManager, …
     └── utils/             # HttpUtil, ObjectUtil, LanguageManager, …
 ```
 ```
 
 
 ## Adding a new page
 ## 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>/`.
 3. Add the page component under `src/pages/<page>/`.
 4. Register the entry in `rollupOptions.input` in `vite.config.js`.
 4. Register the entry in `rollupOptions.input` in `vite.config.js`.
 5. If the page is reachable from the sidebar at `/panel/<route>`, add
 5. If the page is reachable from the sidebar at `/panel/<route>`, add

+ 1 - 1
frontend/api-docs.html

@@ -8,6 +8,6 @@
   <body>
   <body>
     <div id="message"></div>
     <div id="message"></div>
     <div id="app"></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>
   </body>
 </html>
 </html>

+ 1 - 1
frontend/clients.html

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

+ 44 - 31
frontend/eslint.config.js

@@ -1,21 +1,16 @@
 import js from '@eslint/js';
 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';
 import globals from 'globals';
 
 
 export default [
 export default [
   { ignores: ['node_modules/**', '../web/dist/**'] },
   { ignores: ['node_modules/**', '../web/dist/**'] },
   js.configs.recommended,
   js.configs.recommended,
-  ...vue.configs['flat/recommended'],
   {
   {
-    files: ['**/*.{js,vue}'],
+    files: ['**/*.js'],
     languageOptions: {
     languageOptions: {
       ecmaVersion: 2022,
       ecmaVersion: 2022,
       sourceType: 'module',
       sourceType: 'module',
-      parser: vueParser,
-      parserOptions: {
-        ecmaFeatures: { jsx: false },
-      },
       globals: {
       globals: {
         ...globals.browser,
         ...globals.browser,
         ...globals.node,
         ...globals.node,
@@ -29,30 +24,48 @@ export default [
       }],
       }],
       'no-empty': ['error', { allowEmptyCatch: true }],
       'no-empty': ['error', { allowEmptyCatch: true }],
       'no-case-declarations': 'off',
       '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>
   <body>
     <div id="message"></div>
     <div id="message"></div>
     <div id="app"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/inbounds.js"></script>
+    <script type="module" src="/src/entries/inbounds.tsx"></script>
   </body>
   </body>
 </html>
 </html>

+ 1 - 1
frontend/index.html

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

+ 1 - 1
frontend/login.html

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

+ 1 - 1
frontend/nodes.html

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

Filskillnaden har hållts tillbaka eftersom den är för stor
+ 1093 - 158
frontend/package-lock.json


+ 22 - 19
frontend/package.json

@@ -1,9 +1,9 @@
 {
 {
   "name": "3x-ui-frontend",
   "name": "3x-ui-frontend",
   "private": true,
   "private": true,
-  "version": "0.0.3",
+  "version": "0.1.0",
   "type": "module",
   "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": {
   "engines": {
     "node": ">=22.0.0",
     "node": ">=22.0.0",
     "npm": ">=10.0.0"
     "npm": ">=10.0.0"
@@ -12,32 +12,35 @@
     "dev": "vite",
     "dev": "vite",
     "build": "vite build",
     "build": "vite build",
     "preview": "vite preview",
     "preview": "vite preview",
-    "lint": "eslint src"
+    "lint": "eslint src",
+    "typecheck": "tsc --noEmit"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@ant-design/icons-vue": "^7.0.1",
+    "@ant-design/icons": "^6.2.3",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
     "@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",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.20",
     "dayjs": "^1.11.20",
+    "i18next": "^26.2.0",
     "otpauth": "^9.5.1",
     "otpauth": "^9.5.1",
-    "qs": "^6.13.1",
-    "vue": "^3.5.34",
-    "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": {
   "devDependencies": {
     "@eslint/js": "^10.0.1",
     "@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",
     "globals": "^17.6.0",
-    "vite": "8.0.13",
-    "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>
   <body>
     <div id="message"></div>
     <div id="message"></div>
     <div id="app"></div>
     <div id="app"></div>
-    <script type="module" src="/src/entries/settings.js"></script>
+    <script type="module" src="/src/entries/settings.tsx"></script>
   </body>
   </body>
 </html>
 </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 {
 .code-block-wrapper {
   position: relative;
   position: relative;
   border-radius: 6px;
   border-radius: 6px;
@@ -127,9 +63,7 @@ async function copyCode() {
   border: none;
   border: none;
   border-radius: 0;
   border-radius: 0;
 }
 }
-</style>
 
 
-<style>
 .json-key { color: #0550ae; }
 .json-key { color: #0550ae; }
 .json-string { color: #116329; }
 .json-string { color: #116329; }
 .json-number { color: #9a6700; }
 .json-number { color: #9a6700; }
@@ -171,4 +105,3 @@ body.dark .copy-btn:hover {
   color: #58a6ff;
   color: #58a6ff;
   border-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 {
 .api-section {
   background: #fff;
   background: #fff;
   border: 1px solid rgba(128, 128, 128, 0.12);
   border: 1px solid rgba(128, 128, 128, 0.12);
@@ -131,7 +71,7 @@ const endpointLabel = computed(() =>
   margin-bottom: 14px;
   margin-bottom: 14px;
 }
 }
 
 
-.block-label {
+.section-block-label {
   font-size: 12px;
   font-size: 12px;
   font-weight: 600;
   font-weight: 600;
   text-transform: uppercase;
   text-transform: uppercase;
@@ -148,9 +88,7 @@ const endpointLabel = computed(() =>
 .endpoints > :first-child {
 .endpoints > :first-child {
   padding-top: 0;
   padding-top: 0;
 }
 }
-</style>
 
 
-<style>
 body.dark .api-section {
 body.dark .api-section {
   background: #252526;
   background: #252526;
   border-color: rgba(255, 255, 255, 0.08);
   border-color: rgba(255, 255, 255, 0.08);
@@ -181,7 +119,7 @@ body.dark .section-description {
   color: rgba(255, 255, 255, 0.7);
   color: rgba(255, 255, 255, 0.7);
 }
 }
 
 
-body.dark .block-label {
+body.dark .section-block-label {
   color: rgba(255, 255, 255, 0.55);
   color: rgba(255, 255, 255, 0.55);
 }
 }
 
 
@@ -189,4 +127,3 @@ body.dark .endpoint-count {
   color: rgba(255, 255, 255, 0.55);
   color: rgba(255, 255, 255, 0.55);
   background: rgba(255, 255, 255, 0.06);
   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>
+    </>
+  );
+}

Vissa filer visades inte eftersom för många filer har ändrats