2 Commity 8ee79cf447 ... 328d920e98

Autor SHA1 Wiadomość Data
  MHSanaei 328d920e98 feat(mtproto): enforce per-client quota & expiry via mtg-multi limits 9 godzin temu
  Sanaei 61e12e4c29 Frontend dev tooling (Husky, lint-staged, MSW, Storybook) + full React Hook Form migration (#5859) 11 godzin temu
100 zmienionych plików z 7617 dodań i 5327 usunięć
  1. 3 0
      .github/workflows/docs-ci.yml
  2. 9 4
      .github/workflows/release.yml
  3. 7 4
      CLAUDE.md
  4. 5 1
      DockerInit.sh
  5. 1 0
      frontend/.gitignore
  6. 1 0
      frontend/.husky/pre-commit
  7. 25 0
      frontend/.storybook/main.ts
  8. 60 0
      frontend/.storybook/preview.tsx
  9. 11 4
      frontend/CLAUDE.md
  10. 1576 236
      frontend/package-lock.json
  11. 24 2
      frontend/package.json
  12. 349 0
      frontend/public/mockServiceWorker.js
  13. 21 0
      frontend/src/components/clients/ClientSpeedTag.stories.tsx
  14. 29 0
      frontend/src/components/clients/ConfigBlock.stories.tsx
  15. 70 0
      frontend/src/components/feedback/PromptModal.stories.tsx
  16. 51 0
      frontend/src/components/feedback/TextModal.stories.tsx
  17. 93 0
      frontend/src/components/form/rhf/FormField.tsx
  18. 5 0
      frontend/src/components/form/rhf/index.ts
  19. 10 0
      frontend/src/components/form/rhf/normalizeAntdOnChange.ts
  20. 9 0
      frontend/src/components/form/rhf/rhfZodValidate.ts
  21. 5 0
      frontend/src/components/form/rhf/toDotted.ts
  22. 18 0
      frontend/src/components/form/rhf/useZodForm.ts
  23. 27 0
      frontend/src/components/ui/InfinityIcon.stories.tsx
  24. 32 0
      frontend/src/components/ui/InputAddon.stories.tsx
  25. 40 0
      frontend/src/components/ui/SettingListItem.stories.tsx
  26. 34 0
      frontend/src/components/ui/notifications/NotificationCard.stories.tsx
  27. 41 0
      frontend/src/components/viz/Sparkline.stories.tsx
  28. 47 0
      frontend/src/lib/xray/forms/fields/FinalMaskField.tsx
  29. 43 0
      frontend/src/lib/xray/forms/fields/SniffingField.tsx
  30. 34 0
      frontend/src/lib/xray/forms/fields/SockoptCustomField.tsx
  31. 3 0
      frontend/src/lib/xray/forms/fields/index.ts
  32. 30 20
      frontend/src/pages/clients/BulkAddToGroupModal.tsx
  33. 198 197
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  34. 32 44
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  35. 489 494
      frontend/src/pages/clients/ClientFormModal.tsx
  36. 224 207
      frontend/src/pages/hosts/HostFormModal.tsx
  37. 9 7
      frontend/src/pages/hosts/json-forms/HostMuxForm.tsx
  38. 20 16
      frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx
  39. 36 32
      frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx
  40. 225 303
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  41. 13 7
      frontend/src/pages/inbounds/form/SniffingTab.tsx
  42. 54 58
      frontend/src/pages/inbounds/form/advanced-editors.tsx
  43. 34 34
      frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
  44. 5 4
      frontend/src/pages/inbounds/form/protocols/http.tsx
  45. 97 101
      frontend/src/pages/inbounds/form/protocols/hysteria.tsx
  46. 9 8
      frontend/src/pages/inbounds/form/protocols/mixed.tsx
  47. 32 30
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  48. 23 22
      frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
  49. 60 71
      frontend/src/pages/inbounds/form/protocols/tun.tsx
  50. 13 12
      frontend/src/pages/inbounds/form/protocols/tunnel.tsx
  51. 23 7
      frontend/src/pages/inbounds/form/protocols/vless.tsx
  52. 13 11
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  53. 43 44
      frontend/src/pages/inbounds/form/security/reality.tsx
  54. 249 262
      frontend/src/pages/inbounds/form/security/tls.tsx
  55. 10 8
      frontend/src/pages/inbounds/form/transport/grpc.tsx
  56. 11 10
      frontend/src/pages/inbounds/form/transport/httpupgrade.tsx
  57. 15 13
      frontend/src/pages/inbounds/form/transport/kcp.tsx
  58. 95 137
      frontend/src/pages/inbounds/form/transport/raw.tsx
  59. 189 232
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  60. 13 12
      frontend/src/pages/inbounds/form/transport/ws.tsx
  61. 127 119
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  62. 77 63
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  63. 58 55
      frontend/src/pages/login/LoginPage.tsx
  64. 219 231
      frontend/src/pages/nodes/NodeFormModal.tsx
  65. 166 187
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  66. 105 118
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  67. 294 285
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  68. 5 3
      frontend/src/pages/xray/outbounds/protocols/blackhole.tsx
  69. 50 52
      frontend/src/pages/xray/outbounds/protocols/dns.tsx
  70. 213 257
      frontend/src/pages/xray/outbounds/protocols/freedom.tsx
  71. 8 7
      frontend/src/pages/xray/outbounds/protocols/http.tsx
  72. 17 9
      frontend/src/pages/xray/outbounds/protocols/loopback.tsx
  73. 11 7
      frontend/src/pages/xray/outbounds/protocols/server-target.tsx
  74. 13 13
      frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx
  75. 7 5
      frontend/src/pages/xray/outbounds/protocols/socks.tsx
  76. 5 5
      frontend/src/pages/xray/outbounds/protocols/trojan.tsx
  77. 10 10
      frontend/src/pages/xray/outbounds/protocols/vless.tsx
  78. 8 8
      frontend/src/pages/xray/outbounds/protocols/vmess.tsx
  79. 108 106
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  80. 15 13
      frontend/src/pages/xray/outbounds/security/reality.tsx
  81. 15 13
      frontend/src/pages/xray/outbounds/security/tls.tsx
  82. 10 8
      frontend/src/pages/xray/outbounds/transport/grpc.tsx
  83. 8 7
      frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx
  84. 78 104
      frontend/src/pages/xray/outbounds/transport/hysteria.tsx
  85. 15 13
      frontend/src/pages/xray/outbounds/transport/kcp.tsx
  86. 43 48
      frontend/src/pages/xray/outbounds/transport/mux.tsx
  87. 99 126
      frontend/src/pages/xray/outbounds/transport/raw.tsx
  88. 192 208
      frontend/src/pages/xray/outbounds/transport/sockopt.tsx
  89. 10 9
      frontend/src/pages/xray/outbounds/transport/ws.tsx
  90. 226 290
      frontend/src/pages/xray/outbounds/transport/xhttp.tsx
  91. 55 50
      frontend/src/pages/xray/overrides/NordModal.tsx
  92. 51 43
      frontend/src/pages/xray/overrides/WarpModal.tsx
  93. 150 159
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  94. 60 0
      frontend/src/test/http-init-msw.test.ts
  95. 29 27
      frontend/src/test/inbound-form-blocks.test.tsx
  96. 7 0
      frontend/src/test/msw/handlers.ts
  97. 5 0
      frontend/src/test/msw/server.ts
  98. 94 0
      frontend/src/test/rhf-form-field.test.tsx
  99. 7 0
      frontend/src/test/setup.msw.ts
  100. 0 15
      frontend/src/utils/zodForm.ts

+ 3 - 0
.github/workflows/docs-ci.yml

@@ -11,6 +11,9 @@ on:
       - 'docs/**'
       - '.github/workflows/docs-ci.yml'
 
+permissions:
+  contents: read
+
 defaults:
   run:
     working-directory: docs

+ 9 - 4
.github/workflows/release.yml

@@ -158,8 +158,11 @@ jobs:
           mv xray xray-linux-${{ matrix.platform }}
           # mtg-multi (MTProto sidecar) ships prebuilt release binaries whose
           # platform labels match our matrix, so download and unpack the matching
-          # archive. Only the platforms the fork publishes are packaged.
-          MTG_MULTI_VER="v1.14.0"
+          # archive. Only the platforms the fork publishes are packaged. The tag
+          # is resolved from the fork's latest release so it never needs bumping
+          # here; the token only lifts the API rate limit for a public read.
+          MTG_MULTI_VER=$(curl -sfL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
+          if [ -z "$MTG_MULTI_VER" ]; then echo "could not resolve the latest mtg-multi release tag"; exit 1; fi
           case "${{ matrix.platform }}" in
             amd64|arm64|armv7|armv6|386)
               MTG_PKG="mtg-multi-${MTG_MULTI_VER#v}-linux-${{ matrix.platform }}"
@@ -284,8 +287,10 @@ jobs:
           Rename-Item xray.exe xray-windows-amd64.exe
 
           # mtg-multi (MTProto sidecar) publishes a prebuilt Windows binary, so
-          # download and unpack it instead of compiling.
-          $MTG_MULTI_VER = "v1.14.0"
+          # download and unpack it instead of compiling. The tag tracks the
+          # fork's latest release so it never needs bumping here.
+          $MTG_MULTI_VER = (Invoke-RestMethod -Uri "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" -Headers @{ Authorization = "Bearer ${{ secrets.GITHUB_TOKEN }}"; "User-Agent" = "x-ui-release" }).tag_name
+          if (-not $MTG_MULTI_VER) { throw "could not resolve the latest mtg-multi release tag" }
           $MTG_PKG = "mtg-multi-$($MTG_MULTI_VER.TrimStart('v'))-windows-amd64"
           curl.exe -sfLRO "https://github.com/mhsanaei/mtg-multi/releases/download/$MTG_MULTI_VER/$MTG_PKG.zip"
           Expand-Archive -Path "$MTG_PKG.zip" -DestinationPath "mtg-tmp" -Force

+ 7 - 4
CLAUDE.md

@@ -15,10 +15,13 @@ file locations when it can answer in one hop.
   (`github.com/mhsanaei/mtg-multi`, a multi-secret fork built from source;
   `internal/mtproto/`) — outside Xray, one process per inbound serving each
   client's FakeTLS secret via the fork's `[secrets]` section (plus per-client
-  ad-tags via `[secret-ad-tags]`). Client and ad-tag edits are hot-applied
-  through the fork's management API (`PUT /secrets`, bearer-token guarded) so
-  connections survive; the manager falls back to a process restart on older
-  binaries.
+  ad-tags via `[secret-ad-tags]` and per-client data quota / expiry via
+  `[secret-limits]`, mapped from the client's `totalGB`/`expiryTime`). Client,
+  ad-tag and quota/expiry edits are hot-applied through the fork's management API
+  (`PUT /secrets`, bearer-token guarded) so connections survive; the manager
+  falls back to a process restart on older binaries. A client's panel-side
+  traffic reset also calls `POST /secrets/{name}/reset-quota` so a renewed client
+  is not re-blocked by the sidecar's quota counter.
 - Storage: SQLite by default (`/etc/x-ui/x-ui.db` on Linux; the executable dir on
   Windows), PostgreSQL optional (`XUI_DB_TYPE` / `XUI_DB_DSN`). The CGo SQLite
   driver (`mattn/go-sqlite3`) needs a C compiler — `CGO_ENABLED=0` builds fail.

+ 5 - 1
DockerInit.sh

@@ -25,7 +25,11 @@ case $1 in
         FNAME="amd64"
         ;;
 esac
-MTG_MULTI_VER="v1.14.0"
+MTG_MULTI_VER=$(curl -sfL "https://api.github.com/repos/mhsanaei/mtg-multi/releases/latest" | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n 1)
+if [ -z "$MTG_MULTI_VER" ]; then
+    echo "DockerInit: could not resolve the latest mtg-multi release tag" >&2
+    exit 1
+fi
 mkdir -p build/bin
 cd build/bin
 curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.27/Xray-linux-${ARCH}.zip"

+ 1 - 0
frontend/.gitignore

@@ -3,3 +3,4 @@ node_modules/
 *.log
 *.tsbuildinfo
 coverage/
+storybook-static/

+ 1 - 0
frontend/.husky/pre-commit

@@ -0,0 +1 @@
+cd frontend && npx lint-staged

+ 25 - 0
frontend/.storybook/main.ts

@@ -0,0 +1,25 @@
+import type { StorybookConfig } from '@storybook/react-vite';
+
+const config: StorybookConfig = {
+  framework: {
+    name: '@storybook/react-vite',
+    options: {},
+  },
+  stories: ['../src/**/*.stories.@(ts|tsx)'],
+  addons: ['@storybook/addon-docs', '@storybook/addon-a11y'],
+  viteFinal: (viteConfig) => {
+    if (viteConfig.build) {
+      viteConfig.build.outDir = undefined;
+      viteConfig.build.emptyOutDir = false;
+      if (viteConfig.build.rollupOptions) {
+        viteConfig.build.rollupOptions.input = undefined;
+      }
+    }
+    if (viteConfig.experimental) {
+      viteConfig.experimental.renderBuiltUrl = undefined;
+    }
+    return viteConfig;
+  },
+};
+
+export default config;

+ 60 - 0
frontend/.storybook/preview.tsx

@@ -0,0 +1,60 @@
+import { useEffect } from 'react';
+import type { Decorator, Preview } from '@storybook/react-vite';
+import { ConfigProvider, theme as antdTheme } from 'antd';
+import i18next from 'i18next';
+import { initReactI18next } from 'react-i18next';
+
+import enUS from '../../internal/web/translation/en-US.json';
+
+if (!i18next.isInitialized) {
+  void i18next.use(initReactI18next).init({
+    lng: 'en-US',
+    fallbackLng: 'en-US',
+    resources: { 'en-US': { translation: enUS } },
+    interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
+    returnNull: false,
+  });
+}
+
+const withTheme: Decorator = (Story, context) => {
+  const dark = context.globals.theme === 'dark';
+  useEffect(() => {
+    document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
+  }, [dark]);
+  return (
+    <ConfigProvider theme={{ algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm }}>
+      <div style={{ padding: 24, minWidth: 320 }}>
+        <Story />
+      </div>
+    </ConfigProvider>
+  );
+};
+
+const preview: Preview = {
+  decorators: [withTheme],
+  globalTypes: {
+    theme: {
+      description: 'Ant Design theme',
+      defaultValue: 'light',
+      toolbar: {
+        title: 'Theme',
+        icon: 'circlehollow',
+        items: [
+          { value: 'light', title: 'Light' },
+          { value: 'dark', title: 'Dark' },
+        ],
+        dynamicTitle: true,
+      },
+    },
+  },
+  parameters: {
+    controls: {
+      matchers: {
+        color: /(background|color)$/i,
+        date: /Date$/i,
+      },
+    },
+  },
+};
+
+export default preview;

+ 11 - 4
frontend/CLAUDE.md

@@ -27,12 +27,19 @@ The `@` import alias maps to `src/`.
   `src/lib/xray/`. HTTP goes through `HttpUtil` in `src/utils/index.ts`.
 
 ## Rules
-- Ant Design 6 only; no Tailwind/shadcn (a migration was rolled back).
+- Ant Design 6 for components; no Tailwind/shadcn (a migration was rolled back).
+  Form *state* runs on React Hook Form (`src/components/form/rhf/`), not Ant
+  Design's `Form` store.
 - Function components + hooks only; no class components.
 - No `//` line comments in committed TS/TSX. HTML comments are fine.
-- TS strict; `no-explicit-any` is an error. Validate form fields with
-  `antdRule(Schema.shape.field, t)` from `@/utils/zodForm`, not inline
-  `z.string()`.
+- TS strict; `no-explicit-any` is an error. Build forms with `useZodForm` +
+  `FormField` from `@/components/form/rhf` (wrap the tree in `FormProvider`);
+  validate through the `zodResolver` or per-field
+  `rules={{ validate: rhfZodValidate(Schema.shape.field) }}` — messages are Zod
+  issue keys resolved via `t()`, never inline `z.string()`. AntD `<Form>` stays
+  only as a layout wrapper. Complex shared config editors (FinalMask / Sniffing /
+  Sockopt) remain AntD-`Form` islands wrapped as value/onChange adapters in
+  `src/lib/xray/forms/fields/`, bound via a `Controller`.
 - New `g.POST`/`g.GET` route => add it to `src/pages/api-docs/endpoints.ts`,
   then `npm run gen`.
 - i18n strings live in `internal/web/translation/<locale>.json`, NOT under

Plik diff jest za duży
+ 1576 - 236
frontend/package-lock.json


+ 24 - 2
frontend/package.json

@@ -16,14 +16,21 @@
     "typecheck": "tsc --noEmit",
     "test": "vitest run",
     "test:watch": "vitest",
+    "storybook": "storybook dev -p 6006",
+    "build-storybook": "storybook build",
     "gen": "npm run gen:zod && npm run gen:api",
     "gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
-    "gen:zod": "cd .. && go run ./tools/openapigen"
+    "gen:zod": "cd .. && go run ./tools/openapigen",
+    "prepare": "cd .. && husky frontend/.husky || true"
+  },
+  "lint-staged": {
+    "src/**/*.{ts,tsx}": "eslint --fix"
   },
   "dependencies": {
     "@ant-design/icons": "^6.3.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
+    "@hookform/resolvers": "^5.4.0",
     "@noble/hashes": "^2.2.0",
     "@tanstack/react-query": "^5.101.2",
     "@tanstack/react-query-devtools": "^5.101.2",
@@ -35,6 +42,7 @@
     "persian-calendar-suite": "^1.5.5",
     "react": "^19.2.7",
     "react-dom": "^19.2.7",
+    "react-hook-form": "^7.81.0",
     "react-i18next": "^17.0.8",
     "react-router-dom": "^7.18.1",
     "swagger-ui-react": "^5.32.8",
@@ -43,6 +51,9 @@
   },
   "devDependencies": {
     "@eslint/js": "^10.0.1",
+    "@storybook/addon-a11y": "^10.4.6",
+    "@storybook/addon-docs": "^10.4.6",
+    "@storybook/react-vite": "^10.4.6",
     "@testing-library/dom": "^10.4.1",
     "@testing-library/react": "^16.3.2",
     "@types/react": "^19.2.17",
@@ -54,7 +65,11 @@
     "eslint-plugin-jsx-a11y": "^6.10.2",
     "eslint-plugin-react-hooks": "^7.1.1",
     "globals": "^17.7.0",
+    "husky": "^9.1.7",
     "jsdom": "^29.1.1",
+    "lint-staged": "^17.0.8",
+    "msw": "^2.14.7",
+    "storybook": "^10.4.6",
     "typescript": "^6.0.3",
     "typescript-eslint": "^8.62.1",
     "vite": "8.1.3",
@@ -79,6 +94,13 @@
     "@tree-sitter-grammars/tree-sitter-yaml": false,
     "tree-sitter": false,
     "core-js-pure": false,
-    "tree-sitter-json": false
+    "tree-sitter-json": false,
+    "msw": false,
+    "esbuild": false
+  },
+  "msw": {
+    "workerDirectory": [
+      "public"
+    ]
   }
 }

+ 349 - 0
frontend/public/mockServiceWorker.js

@@ -0,0 +1,349 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker.
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ */
+
+const PACKAGE_VERSION = '2.14.7'
+const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
+const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
+const activeClientIds = new Set()
+
+addEventListener('install', function () {
+  self.skipWaiting()
+})
+
+addEventListener('activate', function (event) {
+  event.waitUntil(self.clients.claim())
+})
+
+addEventListener('message', async function (event) {
+  const clientId = Reflect.get(event.source || {}, 'id')
+
+  if (!clientId || !self.clients) {
+    return
+  }
+
+  const client = await self.clients.get(clientId)
+
+  if (!client) {
+    return
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  switch (event.data) {
+    case 'KEEPALIVE_REQUEST': {
+      sendToClient(client, {
+        type: 'KEEPALIVE_RESPONSE',
+      })
+      break
+    }
+
+    case 'INTEGRITY_CHECK_REQUEST': {
+      sendToClient(client, {
+        type: 'INTEGRITY_CHECK_RESPONSE',
+        payload: {
+          packageVersion: PACKAGE_VERSION,
+          checksum: INTEGRITY_CHECKSUM,
+        },
+      })
+      break
+    }
+
+    case 'MOCK_ACTIVATE': {
+      activeClientIds.add(clientId)
+
+      sendToClient(client, {
+        type: 'MOCKING_ENABLED',
+        payload: {
+          client: {
+            id: client.id,
+            frameType: client.frameType,
+          },
+        },
+      })
+      break
+    }
+
+    case 'CLIENT_CLOSED': {
+      activeClientIds.delete(clientId)
+
+      const remainingClients = allClients.filter((client) => {
+        return client.id !== clientId
+      })
+
+      // Unregister itself when there are no more clients
+      if (remainingClients.length === 0) {
+        self.registration.unregister()
+      }
+
+      break
+    }
+  }
+})
+
+addEventListener('fetch', function (event) {
+  const requestInterceptedAt = Date.now()
+
+  // Bypass navigation requests.
+  if (event.request.mode === 'navigate') {
+    return
+  }
+
+  // Opening the DevTools triggers the "only-if-cached" request
+  // that cannot be handled by the worker. Bypass such requests.
+  if (
+    event.request.cache === 'only-if-cached' &&
+    event.request.mode !== 'same-origin'
+  ) {
+    return
+  }
+
+  // Bypass all requests when there are no active clients.
+  // Prevents the self-unregistered worked from handling requests
+  // after it's been terminated (still remains active until the next reload).
+  if (activeClientIds.size === 0) {
+    return
+  }
+
+  const requestId = crypto.randomUUID()
+  event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
+})
+
+/**
+ * @param {FetchEvent} event
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ */
+async function handleRequest(event, requestId, requestInterceptedAt) {
+  const client = await resolveMainClient(event)
+  const requestCloneForEvents = event.request.clone()
+  const response = await getResponse(
+    event,
+    client,
+    requestId,
+    requestInterceptedAt,
+  )
+
+  // Send back the response clone for the "response:*" life-cycle events.
+  // Ensure MSW is active and ready to handle the message, otherwise
+  // this message will pend indefinitely.
+  if (client && activeClientIds.has(client.id)) {
+    const serializedRequest = await serializeRequest(requestCloneForEvents)
+
+    // Clone the response so both the client and the library could consume it.
+    const responseClone = response.clone()
+
+    sendToClient(
+      client,
+      {
+        type: 'RESPONSE',
+        payload: {
+          isMockedResponse: IS_MOCKED_RESPONSE in response,
+          request: {
+            id: requestId,
+            ...serializedRequest,
+          },
+          response: {
+            type: responseClone.type,
+            status: responseClone.status,
+            statusText: responseClone.statusText,
+            headers: Object.fromEntries(responseClone.headers.entries()),
+            body: responseClone.body,
+          },
+        },
+      },
+      responseClone.body ? [serializedRequest.body, responseClone.body] : [],
+    )
+  }
+
+  return response
+}
+
+/**
+ * Resolve the main client for the given event.
+ * Client that issues a request doesn't necessarily equal the client
+ * that registered the worker. It's with the latter the worker should
+ * communicate with during the response resolving phase.
+ * @param {FetchEvent} event
+ * @returns {Promise<Client | undefined>}
+ */
+async function resolveMainClient(event) {
+  const client = await self.clients.get(event.clientId)
+
+  if (activeClientIds.has(event.clientId)) {
+    return client
+  }
+
+  if (client?.frameType === 'top-level') {
+    return client
+  }
+
+  const allClients = await self.clients.matchAll({
+    type: 'window',
+  })
+
+  return allClients
+    .filter((client) => {
+      // Get only those clients that are currently visible.
+      return client.visibilityState === 'visible'
+    })
+    .find((client) => {
+      // Find the client ID that's recorded in the
+      // set of clients that have registered the worker.
+      return activeClientIds.has(client.id)
+    })
+}
+
+/**
+ * @param {FetchEvent} event
+ * @param {Client | undefined} client
+ * @param {string} requestId
+ * @param {number} requestInterceptedAt
+ * @returns {Promise<Response>}
+ */
+async function getResponse(event, client, requestId, requestInterceptedAt) {
+  // Clone the request because it might've been already used
+  // (i.e. its body has been read and sent to the client).
+  const requestClone = event.request.clone()
+
+  function passthrough() {
+    // Cast the request headers to a new Headers instance
+    // so the headers can be manipulated with.
+    const headers = new Headers(requestClone.headers)
+
+    // Remove the "accept" header value that marked this request as passthrough.
+    // This prevents request alteration and also keeps it compliant with the
+    // user-defined CORS policies.
+    const acceptHeader = headers.get('accept')
+    if (acceptHeader) {
+      const values = acceptHeader.split(',').map((value) => value.trim())
+      const filteredValues = values.filter(
+        (value) => value !== 'msw/passthrough',
+      )
+
+      if (filteredValues.length > 0) {
+        headers.set('accept', filteredValues.join(', '))
+      } else {
+        headers.delete('accept')
+      }
+    }
+
+    return fetch(requestClone, { headers })
+  }
+
+  // Bypass mocking when the client is not active.
+  if (!client) {
+    return passthrough()
+  }
+
+  // Bypass initial page load requests (i.e. static assets).
+  // The absence of the immediate/parent client in the map of the active clients
+  // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+  // and is not ready to handle requests.
+  if (!activeClientIds.has(client.id)) {
+    return passthrough()
+  }
+
+  // Notify the client that a request has been intercepted.
+  const serializedRequest = await serializeRequest(event.request)
+  const clientMessage = await sendToClient(
+    client,
+    {
+      type: 'REQUEST',
+      payload: {
+        id: requestId,
+        interceptedAt: requestInterceptedAt,
+        ...serializedRequest,
+      },
+    },
+    [serializedRequest.body],
+  )
+
+  switch (clientMessage.type) {
+    case 'MOCK_RESPONSE': {
+      return respondWithMock(clientMessage.data)
+    }
+
+    case 'PASSTHROUGH': {
+      return passthrough()
+    }
+  }
+
+  return passthrough()
+}
+
+/**
+ * @param {Client} client
+ * @param {any} message
+ * @param {Array<Transferable>} transferrables
+ * @returns {Promise<any>}
+ */
+function sendToClient(client, message, transferrables = []) {
+  return new Promise((resolve, reject) => {
+    const channel = new MessageChannel()
+
+    channel.port1.onmessage = (event) => {
+      if (event.data && event.data.error) {
+        return reject(event.data.error)
+      }
+
+      resolve(event.data)
+    }
+
+    client.postMessage(message, [
+      channel.port2,
+      ...transferrables.filter(Boolean),
+    ])
+  })
+}
+
+/**
+ * @param {Response} response
+ * @returns {Response}
+ */
+function respondWithMock(response) {
+  // Setting response status code to 0 is a no-op.
+  // However, when responding with a "Response.error()", the produced Response
+  // instance will have status code set to 0. Since it's not possible to create
+  // a Response instance with status code 0, handle that use-case separately.
+  if (response.status === 0) {
+    return Response.error()
+  }
+
+  const mockedResponse = new Response(response.body, response)
+
+  Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
+    value: true,
+    enumerable: true,
+  })
+
+  return mockedResponse
+}
+
+/**
+ * @param {Request} request
+ */
+async function serializeRequest(request) {
+  return {
+    url: request.url,
+    mode: request.mode,
+    method: request.method,
+    headers: Object.fromEntries(request.headers.entries()),
+    cache: request.cache,
+    credentials: request.credentials,
+    destination: request.destination,
+    integrity: request.integrity,
+    redirect: request.redirect,
+    referrer: request.referrer,
+    referrerPolicy: request.referrerPolicy,
+    body: await request.arrayBuffer(),
+    keepalive: request.keepalive,
+  }
+}

+ 21 - 0
frontend/src/components/clients/ClientSpeedTag.stories.tsx

@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import { ClientSpeedTag } from './ClientSpeedTag';
+
+const meta = {
+  title: 'Clients/ClientSpeedTag',
+  component: ClientSpeedTag,
+  tags: ['autodocs'],
+} satisfies Meta<typeof ClientSpeedTag>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Active: Story = {
+  args: { speed: { up: 1_450_000, down: 8_900_000 } },
+};
+
+export const Idle: Story = {
+  args: { speed: { up: 0, down: 0 } },
+};

+ 29 - 0
frontend/src/components/clients/ConfigBlock.stories.tsx

@@ -0,0 +1,29 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import ConfigBlock from './ConfigBlock';
+
+const meta = {
+  title: 'Clients/ConfigBlock',
+  component: ConfigBlock,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof ConfigBlock>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+const sampleLink = 'vless://[email protected]:443'
+  + '?type=ws&security=tls&path=%2Fpath#example-node';
+
+export const Collapsed: Story = {
+  args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt' },
+};
+
+export const Expanded: Story = {
+  args: { label: 'vless', text: sampleLink, fileName: 'client-config.txt', defaultOpen: true },
+};
+
+export const WithoutQr: Story = {
+  args: { label: 'trojan', text: sampleLink, fileName: 'client-config.txt', showQr: false, tagColor: 'geekblue' },
+};

+ 70 - 0
frontend/src/components/feedback/PromptModal.stories.tsx

@@ -0,0 +1,70 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Button } from 'antd';
+
+import PromptModal from './PromptModal';
+
+const meta = {
+  title: 'Feedback/PromptModal',
+  component: PromptModal,
+  tags: ['autodocs'],
+} satisfies Meta<typeof PromptModal>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+function InputDemo() {
+  const [open, setOpen] = useState(false);
+  const [value, setValue] = useState('');
+  return (
+    <>
+      <Button type="primary" onClick={() => setOpen(true)}>Rename client</Button>
+      <div style={{ marginTop: 12 }}>Last confirmed: {value || '—'}</div>
+      <PromptModal
+        open={open}
+        title="Enter a new name"
+        initialValue={value}
+        onClose={() => setOpen(false)}
+        onConfirm={(next) => {
+          setValue(next);
+          setOpen(false);
+        }}
+      />
+    </>
+  );
+}
+
+function TextareaDemo() {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <Button onClick={() => setOpen(true)}>Edit note</Button>
+      <PromptModal
+        open={open}
+        type="textarea"
+        title="Edit note"
+        initialValue={'line one\nline two'}
+        onClose={() => setOpen(false)}
+        onConfirm={() => setOpen(false)}
+      />
+    </>
+  );
+}
+
+const placeholderArgs = {
+  open: false,
+  title: '',
+  onClose: () => undefined,
+  onConfirm: () => undefined,
+};
+
+export const Input: Story = {
+  args: placeholderArgs,
+  render: () => <InputDemo />,
+};
+
+export const Textarea: Story = {
+  args: placeholderArgs,
+  render: () => <TextareaDemo />,
+};

+ 51 - 0
frontend/src/components/feedback/TextModal.stories.tsx

@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Button } from 'antd';
+
+import TextModal from './TextModal';
+
+const meta = {
+  title: 'Feedback/TextModal',
+  component: TextModal,
+  tags: ['autodocs'],
+} satisfies Meta<typeof TextModal>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+const jsonSample = JSON.stringify({ outbounds: [{ protocol: 'vless', tag: 'proxy' }] }, null, 2);
+
+function Demo({ json, fileName }: { json?: boolean; fileName?: string }) {
+  const [open, setOpen] = useState(false);
+  return (
+    <>
+      <Button onClick={() => setOpen(true)}>Show config</Button>
+      <TextModal
+        open={open}
+        title="Client configuration"
+        content={json ? jsonSample : 'vless://[email protected]:443#node'}
+        fileName={fileName}
+        json={json}
+        onClose={() => setOpen(false)}
+      />
+    </>
+  );
+}
+
+const placeholderArgs = {
+  open: false,
+  title: '',
+  content: '',
+  onClose: () => undefined,
+};
+
+export const PlainText: Story = {
+  args: placeholderArgs,
+  render: () => <Demo fileName="client.txt" />,
+};
+
+export const Json: Story = {
+  args: placeholderArgs,
+  render: () => <Demo json fileName="config.json" />,
+};

+ 93 - 0
frontend/src/components/form/rhf/FormField.tsx

@@ -0,0 +1,93 @@
+import { cloneElement } from 'react';
+import type { CSSProperties, ReactElement, ReactNode } from 'react';
+import { Controller } from 'react-hook-form';
+import type { Control, ControllerProps, FieldValues, Path } from 'react-hook-form';
+import { Form } from 'antd';
+import { useTranslation } from 'react-i18next';
+
+import { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
+import { toDotted, type FieldName } from './toDotted';
+
+interface FormFieldTransform {
+  input?: (value: unknown) => unknown;
+  output?: (eventValue: unknown) => unknown;
+}
+
+export interface FormFieldProps<T extends FieldValues = FieldValues> {
+  name: FieldName;
+  control?: Control<T>;
+  label?: ReactNode;
+  tooltip?: ReactNode;
+  extra?: ReactNode;
+  valueProp?: ValueProp;
+  transform?: FormFieldTransform;
+  onAfterChange?: (value: unknown) => void;
+  rules?: ControllerProps<T>['rules'];
+  required?: boolean;
+  noStyle?: boolean;
+  className?: string;
+  style?: CSSProperties;
+  children: ReactElement;
+}
+
+export function FormField<T extends FieldValues = FieldValues>({
+  name,
+  control,
+  label,
+  tooltip,
+  extra,
+  valueProp = 'value',
+  transform,
+  onAfterChange,
+  rules,
+  required,
+  noStyle,
+  className,
+  style,
+  children,
+}: FormFieldProps<T>) {
+  const { t } = useTranslation();
+  const dottedName = toDotted(name) as Path<T>;
+
+  return (
+    <Controller
+      control={control}
+      name={dottedName}
+      rules={rules}
+      render={({ field, fieldState }) => {
+        const displayValue = transform?.input ? transform.input(field.value) : field.value;
+        const help = fieldState.error?.message
+          ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
+          : undefined;
+        const childProps: Record<string, unknown> = {
+          [valueProp]: displayValue,
+          onChange: (...args: unknown[]) => {
+            const raw = normalizeAntdOnChange(args, valueProp);
+            const next = transform?.output ? transform.output(raw) : raw;
+            field.onChange(next);
+            onAfterChange?.(next);
+          },
+          onBlur: field.onBlur,
+          ref: field.ref,
+        };
+        return (
+          <Form.Item
+            label={label}
+            tooltip={tooltip}
+            extra={extra}
+            required={required}
+            validateStatus={fieldState.error ? 'error' : undefined}
+            help={help}
+            noStyle={noStyle}
+            className={className}
+            style={style}
+          >
+            {cloneElement(children as ReactElement<Record<string, unknown>>, childProps)}
+          </Form.Item>
+        );
+      }}
+    />
+  );
+}
+
+export default FormField;

+ 5 - 0
frontend/src/components/form/rhf/index.ts

@@ -0,0 +1,5 @@
+export { FormField, type FormFieldProps } from './FormField';
+export { useZodForm } from './useZodForm';
+export { rhfZodValidate } from './rhfZodValidate';
+export { normalizeAntdOnChange, type ValueProp } from './normalizeAntdOnChange';
+export { toDotted, type FieldName } from './toDotted';

+ 10 - 0
frontend/src/components/form/rhf/normalizeAntdOnChange.ts

@@ -0,0 +1,10 @@
+export type ValueProp = 'value' | 'checked';
+
+export function normalizeAntdOnChange(args: unknown[], valueProp: ValueProp): unknown {
+  const first = args[0];
+  if (first !== null && typeof first === 'object' && 'target' in first) {
+    const target = (first as { target: { value?: unknown; checked?: unknown } }).target;
+    return valueProp === 'checked' ? target.checked : target.value;
+  }
+  return first;
+}

+ 9 - 0
frontend/src/components/form/rhf/rhfZodValidate.ts

@@ -0,0 +1,9 @@
+import type { z } from 'zod';
+
+export function rhfZodValidate<S extends z.ZodType>(schema: S) {
+  return (value: unknown): string | true => {
+    const result = schema.safeParse(value);
+    if (result.success) return true;
+    return result.error.issues[0]?.message ?? 'validation.invalid';
+  };
+}

+ 5 - 0
frontend/src/components/form/rhf/toDotted.ts

@@ -0,0 +1,5 @@
+export type FieldName = string | ReadonlyArray<string | number>;
+
+export function toDotted(name: FieldName): string {
+  return typeof name === 'string' ? name : name.join('.');
+}

+ 18 - 0
frontend/src/components/form/rhf/useZodForm.ts

@@ -0,0 +1,18 @@
+import { useForm } from 'react-hook-form';
+import type { FieldValues, Resolver, UseFormProps, UseFormReturn } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import type { z } from 'zod';
+
+export function useZodForm<TFieldValues extends FieldValues>(
+  schema: z.ZodType<TFieldValues>,
+  options?: Omit<UseFormProps<TFieldValues>, 'resolver'>,
+): UseFormReturn<TFieldValues> {
+  const resolver = zodResolver(schema as z.ZodType<TFieldValues, TFieldValues>) as Resolver<TFieldValues>;
+  return useForm<TFieldValues>({
+    mode: 'onSubmit',
+    reValidateMode: 'onChange',
+    shouldUnregister: false,
+    ...options,
+    resolver,
+  });
+}

+ 27 - 0
frontend/src/components/ui/InfinityIcon.stories.tsx

@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import InfinityIcon from './InfinityIcon';
+
+const meta = {
+  title: 'UI/InfinityIcon',
+  component: InfinityIcon,
+  tags: ['autodocs'],
+} satisfies Meta<typeof InfinityIcon>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {};
+
+export const Large: Story = {
+  args: { width: 48, height: 34 },
+};
+
+export const InlineWithText: Story = {
+  render: () => (
+    <span style={{ fontSize: 16 }}>
+      Unlimited traffic <InfinityIcon />
+    </span>
+  ),
+};

+ 32 - 0
frontend/src/components/ui/InputAddon.stories.tsx

@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Input, Space } from 'antd';
+
+import InputAddon from './InputAddon';
+
+const meta = {
+  title: 'UI/InputAddon',
+  component: InputAddon,
+  tags: ['autodocs'],
+} satisfies Meta<typeof InputAddon>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Static: Story = {
+  args: { children: 'https://' },
+};
+
+export const Clickable: Story = {
+  args: { children: 'Copy', ariaLabel: 'Copy value', onClick: () => undefined },
+};
+
+export const BesideInput: Story = {
+  args: { children: 'https://' },
+  render: () => (
+    <Space.Compact>
+      <InputAddon>https://</InputAddon>
+      <Input defaultValue="panel.example.com" style={{ width: 220 }} />
+    </Space.Compact>
+  ),
+};

+ 40 - 0
frontend/src/components/ui/SettingListItem.stories.tsx

@@ -0,0 +1,40 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { InputNumber, Switch } from 'antd';
+
+import SettingListItem from './SettingListItem';
+
+const meta = {
+  title: 'UI/SettingListItem',
+  component: SettingListItem,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof SettingListItem>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const WithSwitch: Story = {
+  args: {
+    title: 'Enable subscription',
+    description: 'Expose an aggregated subscription URL for this client.',
+    control: <Switch defaultChecked />,
+  },
+};
+
+export const WithNumber: Story = {
+  args: {
+    title: 'Traffic limit',
+    description: 'Cap total traffic in gigabytes. Zero means unlimited.',
+    control: <InputNumber min={0} defaultValue={100} style={{ width: '100%' }} />,
+  },
+};
+
+export const CompactPadding: Story = {
+  args: {
+    paddings: 'small',
+    title: 'Auto renew',
+    description: 'Restart the quota window automatically on depletion.',
+    control: <Switch />,
+  },
+};

+ 34 - 0
frontend/src/components/ui/notifications/NotificationCard.stories.tsx

@@ -0,0 +1,34 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+import { Switch } from 'antd';
+import { BellOutlined } from '@ant-design/icons';
+
+import { NotificationCard } from './NotificationCard';
+
+const meta = {
+  title: 'UI/Notifications/NotificationCard',
+  component: NotificationCard,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof NotificationCard>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: {
+    icon: <BellOutlined />,
+    title: 'Telegram',
+    extra: <Switch defaultChecked />,
+    children: <span>Push a message to the configured chat when an event fires.</span>,
+  },
+};
+
+export const Disabled: Story = {
+  args: {
+    icon: <BellOutlined />,
+    title: 'Email',
+    extra: <Switch />,
+    children: <span>Email delivery is turned off for this channel.</span>,
+  },
+};

+ 41 - 0
frontend/src/components/viz/Sparkline.stories.tsx

@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from '@storybook/react-vite';
+
+import Sparkline from './Sparkline';
+
+const wave = Array.from({ length: 48 }, (_, i) => 45 + Math.round(28 * Math.sin(i / 4) + (i % 5) * 3));
+const inverse = wave.map((v) => Math.max(0, 100 - v));
+
+const meta = {
+  title: 'Viz/Sparkline',
+  component: Sparkline,
+  tags: ['autodocs'],
+  parameters: { layout: 'padded' },
+} satisfies Meta<typeof Sparkline>;
+
+export default meta;
+
+type Story = StoryObj<typeof meta>;
+
+export const Default: Story = {
+  args: { data: wave, height: 80 },
+};
+
+export const AxesAndGrid: Story = {
+  args: { data: wave, height: 140, showAxes: true, showGrid: true, name1: 'CPU' },
+};
+
+export const Extrema: Story = {
+  args: { data: wave, height: 140, name1: 'CPU', extrema: { show: true } },
+};
+
+export const MultiSeriesTooltip: Story = {
+  args: {
+    data: wave,
+    data2: inverse,
+    name1: 'Upload',
+    name2: 'Download',
+    height: 140,
+    showTooltip: true,
+    showAxes: true,
+  },
+};

+ 47 - 0
frontend/src/lib/xray/forms/fields/FinalMaskField.tsx

@@ -0,0 +1,47 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
+
+interface FinalMaskFieldProps {
+  value?: FinalMaskStreamSettings;
+  onChange?: (next: FinalMaskStreamSettings) => void;
+  network: string;
+  protocol: string;
+  showAll?: boolean;
+}
+
+const EMPTY: FinalMaskStreamSettings = { tcp: [], udp: [] };
+
+export default function FinalMaskField({ value, onChange, network, protocol, showAll }: FinalMaskFieldProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => value ?? EMPTY);
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+  const lastEmitted = useRef(JSON.stringify(initial));
+
+  const finalmask = Form.useWatch('finalmask', form) as FinalMaskStreamSettings | undefined;
+
+  useEffect(() => {
+    if (finalmask === undefined) return;
+    const serialized = JSON.stringify(finalmask);
+    if (serialized === lastEmitted.current) return;
+    lastEmitted.current = serialized;
+    onChangeRef.current?.(finalmask);
+  }, [finalmask]);
+
+  return (
+    <Form
+      form={form}
+      component={false}
+      colon={false}
+      labelCol={{ sm: { span: 8 } }}
+      wrapperCol={{ sm: { span: 14 } }}
+      labelWrap
+      initialValues={{ finalmask: initial }}
+    >
+      <FinalMaskForm name="finalmask" network={network} protocol={protocol} form={form} showAll={showAll} />
+    </Form>
+  );
+}

+ 43 - 0
frontend/src/lib/xray/forms/fields/SniffingField.tsx

@@ -0,0 +1,43 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import type { Sniffing } from '@/schemas/primitives/sniffing';
+
+interface SniffingFieldProps {
+  value?: Sniffing;
+  onChange?: (next: Sniffing) => void;
+  enableLabel: string;
+}
+
+export default function SniffingField({ value, onChange, enableLabel }: SniffingFieldProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => value ?? ({} as Sniffing));
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+  const lastEmitted = useRef(JSON.stringify(initial));
+
+  const sniffing = Form.useWatch('sniffing', form) as Sniffing | undefined;
+
+  useEffect(() => {
+    if (sniffing === undefined) return;
+    const serialized = JSON.stringify(sniffing);
+    if (serialized === lastEmitted.current) return;
+    lastEmitted.current = serialized;
+    onChangeRef.current?.(sniffing);
+  }, [sniffing]);
+
+  return (
+    <Form
+      form={form}
+      component={false}
+      colon={false}
+      labelCol={{ sm: { span: 8 } }}
+      wrapperCol={{ sm: { span: 14 } }}
+      labelWrap
+      initialValues={{ sniffing: initial }}
+    >
+      <SniffingFields name={['sniffing']} form={form} enableLabel={enableLabel} />
+    </Form>
+  );
+}

+ 34 - 0
frontend/src/lib/xray/forms/fields/SockoptCustomField.tsx

@@ -0,0 +1,34 @@
+import { useEffect, useRef, useState } from 'react';
+import { Form } from 'antd';
+
+import { CustomSockoptList } from '@/components/form';
+import type { CustomSockopt } from '@/schemas/protocols/stream/sockopt';
+
+interface SockoptCustomFieldProps {
+  value?: CustomSockopt[];
+  onChange?: (next: CustomSockopt[]) => void;
+}
+
+export default function SockoptCustomField({ value, onChange }: SockoptCustomFieldProps) {
+  const [form] = Form.useForm();
+  const [initial] = useState(() => value ?? []);
+  const onChangeRef = useRef(onChange);
+  onChangeRef.current = onChange;
+  const lastEmitted = useRef(JSON.stringify(initial));
+
+  const list = Form.useWatch('customSockopt', form) as CustomSockopt[] | undefined;
+
+  useEffect(() => {
+    if (list === undefined) return;
+    const serialized = JSON.stringify(list);
+    if (serialized === lastEmitted.current) return;
+    lastEmitted.current = serialized;
+    onChangeRef.current?.(list);
+  }, [list]);
+
+  return (
+    <Form form={form} component={false} initialValues={{ customSockopt: initial }}>
+      <CustomSockoptList name={['customSockopt']} />
+    </Form>
+  );
+}

+ 3 - 0
frontend/src/lib/xray/forms/fields/index.ts

@@ -0,0 +1,3 @@
+export { default as FinalMaskField } from './FinalMaskField';
+export { default as SniffingField } from './SniffingField';
+export { default as SockoptCustomField } from './SockoptCustomField';

+ 30 - 20
frontend/src/pages/clients/BulkAddToGroupModal.tsx

@@ -1,6 +1,13 @@
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { AutoComplete, Form, Modal, message } from 'antd';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
+
+import { FormField } from '@/components/form/rhf';
+
+type GroupFormValues = { group: string };
+
+const EMPTY: GroupFormValues = { group: '' };
 
 interface BulkAddToGroupModalProps {
   open: boolean;
@@ -19,15 +26,16 @@ export default function BulkAddToGroupModal({
 }: BulkAddToGroupModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const [value, setValue] = useState('');
   const [submitting, setSubmitting] = useState(false);
+  const methods = useForm<GroupFormValues>({ defaultValues: EMPTY });
+  const group = useWatch({ control: methods.control, name: 'group' });
 
   useEffect(() => {
-    if (open) setValue('');
-  }, [open]);
+    if (open) methods.reset(EMPTY);
+  }, [open, methods]);
 
   async function submit() {
-    const next = value.trim();
+    const next = (methods.getValues().group ?? '').trim();
     if (!next) return;
     setSubmitting(true);
     try {
@@ -51,26 +59,28 @@ export default function BulkAddToGroupModal({
         okText={t('add')}
         cancelText={t('cancel')}
         confirmLoading={submitting}
-        okButtonProps={{ disabled: !value.trim() }}
+        okButtonProps={{ disabled: !(group ?? '').trim() }}
         onCancel={() => onOpenChange(false)}
         onOk={submit}
         destroyOnHidden
       >
-        <Form layout="vertical">
-          <Form.Item
-            label={t('pages.clients.group')}
-            tooltip={t('pages.clients.addToGroupTooltip')}
-          >
-            <AutoComplete
-              value={value}
-              placeholder={t('pages.clients.groupName')}
-              options={groups.map((g) => ({ value: g }))}
-              onChange={(v) => setValue(v ?? '')}
-              allowClear
-              autoFocus
-            />
-          </Form.Item>
-        </Form>
+        <FormProvider {...methods}>
+          <Form layout="vertical">
+            <FormField
+              name="group"
+              label={t('pages.clients.group')}
+              tooltip={t('pages.clients.addToGroupTooltip')}
+              transform={{ output: (v) => v ?? '' }}
+            >
+              <AutoComplete
+                placeholder={t('pages.clients.groupName')}
+                options={groups.map((g) => ({ value: g }))}
+                allowClear
+                autoFocus
+              />
+            </FormField>
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 198 - 197
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -4,11 +4,13 @@ import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, S
 import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { RandomUtil, SizeFormatter } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 import { useClients, type InboundOption } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
@@ -19,6 +21,24 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard',
 ]);
 
+const EMPTY: ClientBulkAddFormValues = {
+  emailMethod: 0,
+  firstNum: 1,
+  lastNum: 1,
+  emailPrefix: '',
+  emailPostfix: '',
+  quantity: 1,
+  subId: '',
+  group: '',
+  comment: '',
+  flow: '',
+  limitIp: 0,
+  totalGB: 0,
+  expiryTime: 0,
+  reset: 0,
+  inboundIds: [],
+};
+
 interface ClientBulkAddModalProps {
   open: boolean;
   inbounds: InboundOption[];
@@ -27,28 +47,6 @@ interface ClientBulkAddModalProps {
   onSaved?: () => void;
 }
 
-type FormState = ClientBulkAddFormValues;
-
-function emptyForm(): FormState {
-  return {
-    emailMethod: 0,
-    firstNum: 1,
-    lastNum: 1,
-    emailPrefix: '',
-    emailPostfix: '',
-    quantity: 1,
-    subId: '',
-    group: '',
-    comment: '',
-    flow: '',
-    limitIp: 0,
-    totalGB: 0,
-    expiryTime: 0,
-    reset: 0,
-    inboundIds: [],
-  };
-}
-
 export default function ClientBulkAddModal({
   open,
   inbounds,
@@ -60,7 +58,14 @@ export default function ClientBulkAddModal({
   const [messageApi, messageContextHolder] = message.useMessage();
   const { bulkCreate } = useClients();
 
-  const [form, setForm] = useState<FormState>(emptyForm);
+  const methods = useForm<ClientBulkAddFormValues>({ defaultValues: EMPTY });
+  const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
+  const emailMethod = useWatch({ control: methods.control, name: 'emailMethod' });
+  const firstNum = useWatch({ control: methods.control, name: 'firstNum' });
+  const flow = useWatch({ control: methods.control, name: 'flow' });
+  const expiryTime = useWatch({ control: methods.control, name: 'expiryTime' });
+  const subId = useWatch({ control: methods.control, name: 'subId' });
+  const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
   const [delayedStart, setDelayedStart] = useState(false);
   const [saving, setSaving] = useState(false);
   const fail2ban = useFail2banStatusQuery();
@@ -70,14 +75,10 @@ export default function ClientBulkAddModal({
   useEffect(() => {
     if (!open) return;
 
-    setForm(emptyForm());
+    methods.reset(EMPTY);
     setDelayedStart(false);
 
-  }, [open]);
-
-  function update<K extends keyof FormState>(key: K, value: FormState[K]) {
-    setForm((prev) => ({ ...prev, [key]: value }));
-  }
+  }, [open, methods]);
 
   const flowCapableIds = useMemo(() => {
     const ids = new Set<number>();
@@ -88,25 +89,25 @@ export default function ClientBulkAddModal({
   }, [inbounds]);
 
   const showFlow = useMemo(
-    () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
-    [form.inboundIds, flowCapableIds],
+    () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
+    [inboundIds, flowCapableIds],
   );
 
   const ss2022Method = useMemo(() => {
-    for (const id of form.inboundIds || []) {
+    for (const id of inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
       const method = ib?.ssMethod;
       if (method && method.substring(0, 4) === '2022') return method;
     }
     return '';
-  }, [form.inboundIds, inbounds]);
+  }, [inboundIds, inbounds]);
 
   useEffect(() => {
-    if (!showFlow && form.flow) {
+    if (!showFlow && flow) {
 
-      update('flow', '');
+      methods.setValue('flow', '');
     }
-  }, [showFlow, form.flow]);
+  }, [showFlow, flow, methods]);
 
   const inboundOptions = useMemo(
     () => (inbounds || [])
@@ -119,27 +120,27 @@ export default function ClientBulkAddModal({
   );
 
   const expiryDate = useMemo<Dayjs | null>(
-    () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
-    [form.expiryTime],
+    () => (expiryTime > 0 ? dayjs(expiryTime) : null),
+    [expiryTime],
   );
 
-  const delayedExpireDays = form.expiryTime < 0 ? form.expiryTime / -86400000 : 0;
+  const delayedExpireDays = expiryTime < 0 ? expiryTime / -86400000 : 0;
 
-  function buildEmails(): string[] {
-    const method = form.emailMethod;
+  function buildEmails(values: ClientBulkAddFormValues): string[] {
+    const method = values.emailMethod;
     const out: string[] = [];
     let start: number;
     let end: number;
     if (method > 1) {
-      start = form.firstNum;
-      end = form.lastNum + 1;
+      start = values.firstNum;
+      end = values.lastNum + 1;
     } else {
       start = 0;
-      end = form.quantity;
+      end = values.quantity;
     }
-    const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
+    const prefix = method > 0 && values.emailPrefix.length > 0 ? values.emailPrefix : '';
     const useNum = method > 1;
-    const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
+    const postfix = method > 2 && values.emailPostfix.length > 0 ? values.emailPostfix : '';
     for (let i = start; i < end; i++) {
       let email = '';
       if (method !== 4) email = RandomUtil.randomLowerAndNum(10);
@@ -150,12 +151,13 @@ export default function ClientBulkAddModal({
   }
 
   async function submit() {
-    const validated = ClientBulkAddFormSchema.safeParse(form);
+    const current = methods.getValues();
+    const validated = ClientBulkAddFormSchema.safeParse(current);
     if (!validated.success) {
       messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
     }
-    const emails = buildEmails();
+    const emails = buildEmails(current);
     if (emails.length === 0) return;
 
     setSaving(true);
@@ -163,22 +165,22 @@ export default function ClientBulkAddModal({
       const payloads = emails.map((email) => ({
         client: {
           email,
-          subId: form.subId || RandomUtil.randomLowerAndNum(16),
+          subId: current.subId || RandomUtil.randomLowerAndNum(16),
           id: RandomUtil.randomUUID(),
           password: ss2022Method
             ? RandomUtil.randomShadowsocksPassword(ss2022Method)
             : RandomUtil.randomLowerAndNum(16),
           auth: RandomUtil.randomLowerAndNum(16),
-          flow: showFlow ? (form.flow || '') : '',
-          totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
-          expiryTime: form.expiryTime,
-          reset: Number(form.reset) || 0,
-          limitIp: Number(form.limitIp) || 0,
-          group: form.group,
-          comment: form.comment,
+          flow: showFlow ? (current.flow || '') : '',
+          totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB),
+          expiryTime: current.expiryTime,
+          reset: Number(current.reset) || 0,
+          limitIp: Number(current.limitIp) || 0,
+          group: current.group,
+          comment: current.comment,
           enable: true,
         },
-        inboundIds: form.inboundIds,
+        inboundIds: current.inboundIds,
       }));
       const msg = await bulkCreate(payloads);
       const ok = msg?.obj?.created ?? 0;
@@ -213,157 +215,156 @@ export default function ClientBulkAddModal({
         onOk={submit}
         onCancel={() => onOpenChange(false)}
       >
-        <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
-          <Form.Item label={t('pages.clients.attachedInbounds')} required>
-            <SelectAllClearButtons
-              options={inboundOptions}
-              value={form.inboundIds}
-              onChange={(v) => update('inboundIds', v)}
-            />
-            <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={1000} onChange={(v) => update('quantity', Number(v) || 1)} />
-            </Form.Item>
-          )}
-
-          <Form.Item label={t('pages.clients.subId')}>
-            <Space.Compact style={{ display: 'flex' }}>
-              <Input
-                value={form.subId}
-                onChange={(e) => update('subId', e.target.value)}
-                style={{ flex: 1 }}
+        <FormProvider {...methods}>
+          <Form colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }}>
+            <Form.Item label={t('pages.clients.attachedInbounds')} required>
+              <SelectAllClearButtons
+                options={inboundOptions}
+                value={inboundIds}
+                onChange={(v) => methods.setValue('inboundIds', v)}
               />
-              <Button
-                aria-label={t('regenerate')}
-                icon={<ReloadOutlined />}
-                onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
+              <Select
+                mode="multiple"
+                value={inboundIds}
+                onChange={(v) => methods.setValue('inboundIds', v)}
+                options={inboundOptions}
+                placeholder={t('pages.clients.selectInbound')}
+                showSearch={{
+                  filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+                }}
               />
-            </Space.Compact>
-          </Form.Item>
-
-          <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
-            <AutoComplete
-              value={form.group}
-              placeholder={t('pages.clients.groupPlaceholder')}
-              options={groups.map((g) => ({ value: g }))}
-              onChange={(v) => update('group', v ?? '')}
-              allowClear
-            />
-          </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')}>
+            </Form.Item>
+
+            <FormField name="emailMethod" label={t('pages.clients.method')}>
               <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 })),
+                  { 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' },
                 ]}
               />
+            </FormField>
+
+            {emailMethod > 1 && (
+              <>
+                <FormField name="firstNum" label={t('pages.clients.first')} transform={{ output: (v) => Number(v) || 1 }}>
+                  <InputNumber min={1} />
+                </FormField>
+                <FormField name="lastNum" label={t('pages.clients.last')} transform={{ output: (v) => Number(v) || 1 }}>
+                  <InputNumber min={firstNum} />
+                </FormField>
+              </>
+            )}
+            {emailMethod > 0 && (
+              <FormField name="emailPrefix" label={t('pages.clients.prefix')}>
+                <Input />
+              </FormField>
+            )}
+            {emailMethod > 2 && (
+              <FormField name="emailPostfix" label={t('pages.clients.postfix')}>
+                <Input />
+              </FormField>
+            )}
+            {emailMethod < 2 && (
+              <FormField name="quantity" label={t('pages.clients.clientCount')} transform={{ output: (v) => Number(v) || 1 }}>
+                <InputNumber min={1} max={1000} />
+              </FormField>
+            )}
+
+            <Form.Item label={t('pages.clients.subId')}>
+              <Space.Compact style={{ display: 'flex' }}>
+                <Input
+                  value={subId}
+                  onChange={(e) => methods.setValue('subId', e.target.value)}
+                  style={{ flex: 1 }}
+                />
+                <Button
+                  aria-label={t('regenerate')}
+                  icon={<ReloadOutlined />}
+                  onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))}
+                />
+              </Space.Compact>
             </Form.Item>
-          )}
-
-          <Form.Item label={t('pages.clients.limitIp')}>
-            <Tooltip title={limitIpNotice || undefined}>
-              <span style={{ display: 'inline-flex' }}>
-                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
-                  style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
-                  onChange={(v) => update('limitIp', Number(v) || 0)} />
-              </span>
-            </Tooltip>
-          </Form.Item>
-
-          <Form.Item label={t('pages.clients.totalGB')}>
-            <InputNumber value={form.totalGB} min={0} step={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))}
+
+            <FormField
+              name="group"
+              label={t('pages.clients.group')}
+              tooltip={t('pages.clients.groupDesc')}
+              transform={{ output: (v) => v ?? '' }}
+            >
+              <AutoComplete
+                placeholder={t('pages.clients.groupPlaceholder')}
+                options={groups.map((g) => ({ value: g }))}
+                allowClear
               />
+            </FormField>
+
+            <FormField name="comment" label={t('comment')}>
+              <Input />
+            </FormField>
+
+            {showFlow && (
+              <FormField name="flow" label={t('pages.clients.flow')}>
+                <Select
+                  style={{ width: 220 }}
+                  options={[
+                    { value: '', label: t('none') },
+                    ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                  ]}
+                />
+              </FormField>
+            )}
+
+            <Form.Item label={t('pages.clients.limitIp')}>
+              <Tooltip title={limitIpNotice || undefined}>
+                <span style={{ display: 'inline-flex' }}>
+                  <InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
+                    style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
+                    onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
+                </span>
+              </Tooltip>
             </Form.Item>
-          ) : (
-            <Form.Item label={t('pages.inbounds.expireDate')}>
-              <DateTimePicker
-                value={expiryDate}
-                onChange={(next) => update('expiryTime', next ? next.valueOf() : 0)}
+
+            <FormField name="totalGB" label={t('pages.clients.totalGB')} transform={{ output: (v) => Number(v) || 0 }}>
+              <InputNumber min={0} step={1} />
+            </FormField>
+
+            <Form.Item label={t('pages.clients.delayedStart')}>
+              <Switch
+                checked={delayedStart}
+                onClick={() => { setDelayedStart(!delayedStart); methods.setValue('expiryTime', 0); }}
               />
             </Form.Item>
-          )}
-
-          <Form.Item
-            label={t('pages.clients.renew')}
-            tooltip={t('pages.clients.renewDesc')}
-          >
-            <InputNumber
-              value={form.reset}
-              min={0}
-              onChange={(v) => update('reset', Number(v) || 0)}
-            />
-          </Form.Item>
-        </Form>
+
+            {delayedStart ? (
+              <Form.Item label={t('pages.clients.expireDays')}>
+                <InputNumber
+                  value={delayedExpireDays}
+                  min={0}
+                  onChange={(v) => methods.setValue('expiryTime', -86400000 * (Number(v) || 0))}
+                />
+              </Form.Item>
+            ) : (
+              <Form.Item label={t('pages.inbounds.expireDate')}>
+                <DateTimePicker
+                  value={expiryDate}
+                  onChange={(next) => methods.setValue('expiryTime', next ? next.valueOf() : 0)}
+                />
+              </Form.Item>
+            )}
+
+            <FormField
+              name="reset"
+              label={t('pages.clients.renew')}
+              tooltip={t('pages.clients.renewDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} />
+            </FormField>
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 32 - 44
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -1,14 +1,18 @@
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
+import { FormProvider, useForm } from 'react-hook-form';
 
-import { ClientBulkAdjustFormSchema } from '@/schemas/client';
+import { ClientBulkAdjustFormSchema, type ClientBulkAdjustFormValues } from '@/schemas/client';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
+import { FormField } from '@/components/form/rhf';
 
 const GB = 1024 * 1024 * 1024;
 
 const FLOW_CLEAR = 'none';
 
+const EMPTY: ClientBulkAdjustFormValues = { addDays: 0, addGB: 0, flow: '' };
+
 interface ClientBulkAdjustModalProps {
   open: boolean;
   count: number;
@@ -19,24 +23,19 @@ interface ClientBulkAdjustModalProps {
 export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const [addDays, setAddDays] = useState<number>(0);
-  const [addGB, setAddGB] = useState<number>(0);
-  const [flow, setFlow] = useState<string>('');
   const [submitting, setSubmitting] = useState(false);
+  const methods = useForm<ClientBulkAdjustFormValues>({ defaultValues: EMPTY });
 
   useEffect(() => {
-    if (open) {
-      setAddDays(0);
-      setAddGB(0);
-      setFlow('');
-    }
-  }, [open]);
+    if (open) methods.reset(EMPTY);
+  }, [open, methods]);
 
   async function handleOk() {
+    const values = methods.getValues();
     const validated = ClientBulkAdjustFormSchema.safeParse({
-      addDays: Math.trunc(Number(addDays) || 0),
-      addGB: Number(addGB) || 0,
-      flow,
+      addDays: Math.trunc(Number(values.addDays) || 0),
+      addGB: Number(values.addGB) || 0,
+      flow: values.flow,
     });
     if (!validated.success) {
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
@@ -83,37 +82,26 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
           style={{ marginBottom: 16 }}
           title={t('pages.clients.bulkAdjustHint')}
         />
-        <Form layout="vertical">
-          <Form.Item label={t('pages.clients.addDays')}>
-            <InputNumber
-              value={addDays}
-              onChange={(v) => setAddDays(Number(v) || 0)}
-              style={{ width: '100%' }}
-              step={1}
-              precision={0}
-            />
-          </Form.Item>
-          <Form.Item label={t('pages.clients.addTrafficGB')}>
-            <InputNumber
-              value={addGB}
-              onChange={(v) => setAddGB(Number(v) || 0)}
-              style={{ width: '100%' }}
-              step={1}
-            />
-          </Form.Item>
-          <Form.Item label={t('pages.clients.bulkFlow')}>
-            <Select
-              value={flow}
-              onChange={setFlow}
-              style={{ width: '100%' }}
-              options={[
-                { value: '', label: t('pages.clients.bulkFlowNoChange') },
-                { value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
-                ...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
-              ]}
-            />
-          </Form.Item>
-        </Form>
+        <FormProvider {...methods}>
+          <Form layout="vertical">
+            <FormField name="addDays" label={t('pages.clients.addDays')}>
+              <InputNumber style={{ width: '100%' }} step={1} precision={0} />
+            </FormField>
+            <FormField name="addGB" label={t('pages.clients.addTrafficGB')}>
+              <InputNumber style={{ width: '100%' }} step={1} />
+            </FormField>
+            <FormField name="flow" label={t('pages.clients.bulkFlow')}>
+              <Select
+                style={{ width: '100%' }}
+                options={[
+                  { value: '', label: t('pages.clients.bulkFlowNoChange') },
+                  { value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
+                  ...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
+                ]}
+              />
+            </FormField>
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 489 - 494
frontend/src/pages/clients/ClientFormModal.tsx

@@ -22,15 +22,18 @@ import {
 import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
+import { FormProvider, useForm, useWatch, useFieldArray } from 'react-hook-form';
+
 import { HttpUtil, RandomUtil, Wireguard } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { generateMtprotoSecret } from '@/lib/xray/inbound-defaults';
 import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
-import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
+import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
@@ -42,9 +45,7 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
 const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
 
-// One editable row in the Links tab. `key` is a stable client-side id for React.
 interface ExternalLinkRow {
-  key: number;
   kind: 'link' | 'subscription';
   value: string;
 }
@@ -93,26 +94,8 @@ interface ClientFormModalProps {
   onOpenChange: (open: boolean) => void;
 }
 
-interface FormState {
-  email: string;
-  subId: string;
-  uuid: string;
-  password: string;
-  auth: string;
-  flow: string;
-  security: string;
-  reverseTag: string;
-  totalGB: number;
-  expiryDate: Dayjs | null;
-  delayedStart: boolean;
-  delayedDays: number;
-  reset: number;
-  limitIp: number;
-  tgId: number;
-  group: string;
-  comment: string;
-  enable: boolean;
-  inboundIds: number[];
+type Values = ClientFormValues & {
+  expiryDate: number;
   externalLinks: ExternalLinkRow[];
   wgPrivateKey: string;
   wgPublicKey: string;
@@ -120,43 +103,39 @@ interface FormState {
   wgAllowedIPs: string;
   secret: string;
   adTag: string;
-}
+};
+
+const EMPTY: Values = {
+  email: '',
+  subId: '',
+  uuid: '',
+  password: '',
+  auth: '',
+  flow: '',
+  security: 'auto',
+  reverseTag: '',
+  totalGB: 0,
+  expiryDate: 0,
+  delayedStart: false,
+  delayedDays: 0,
+  reset: 0,
+  limitIp: 0,
+  tgId: 0,
+  group: '',
+  comment: '',
+  enable: true,
+  inboundIds: [],
+  externalLinks: [],
+  wgPrivateKey: '',
+  wgPublicKey: '',
+  wgPreSharedKey: '',
+  wgAllowedIPs: '',
+  secret: '',
+  adTag: '',
+};
 
-function emptyForm(): FormState {
-  return {
-    email: '',
-    subId: '',
-    uuid: '',
-    password: '',
-    auth: '',
-    flow: '',
-    security: 'auto',
-    reverseTag: '',
-    totalGB: 0,
-    expiryDate: null,
-    delayedStart: false,
-    delayedDays: 0,
-    reset: 0,
-    limitIp: 0,
-    tgId: 0,
-    group: '',
-    comment: '',
-    enable: true,
-    inboundIds: [],
-    externalLinks: [],
-    wgPrivateKey: '',
-    wgPublicKey: '',
-    wgPreSharedKey: '',
-    wgAllowedIPs: '',
-    secret: '',
-    adTag: '',
-  };
-}
-
-let externalLinkRowSeq = 0;
 function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
   return (links || []).map((l) => ({
-    key: (externalLinkRowSeq += 1),
     kind: l.kind === 'subscription' ? 'subscription' : 'link',
     value: l.value || '',
   }));
@@ -189,7 +168,27 @@ export default function ClientFormModal({
   const [messageApi, messageContextHolder] = message.useMessage();
   const isEdit = mode === 'edit';
 
-  const [form, setForm] = useState<FormState>(emptyForm);
+  const methods = useForm<Values>({ defaultValues: EMPTY });
+  const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
+  const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
+  const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
+  const enable = useWatch({ control: methods.control, name: 'enable' });
+  const flow = useWatch({ control: methods.control, name: 'flow' });
+  const reverseTag = useWatch({ control: methods.control, name: 'reverseTag' });
+  const secret = useWatch({ control: methods.control, name: 'secret' });
+  const email = useWatch({ control: methods.control, name: 'email' });
+  const uuid = useWatch({ control: methods.control, name: 'uuid' });
+  const password = useWatch({ control: methods.control, name: 'password' });
+  const subId = useWatch({ control: methods.control, name: 'subId' });
+  const auth = useWatch({ control: methods.control, name: 'auth' });
+  const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
+  const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
+  const {
+    fields: externalLinkFields,
+    append: appendExternalLink,
+    remove: removeExternalLink,
+  } = useFieldArray({ control: methods.control, name: 'externalLinks' });
+
   const [submitting, setSubmitting] = useState(false);
   const [resetting, setResetting] = useState(false);
   const [clientIps, setClientIps] = useState<ClientIpInfo[]>([]);
@@ -200,29 +199,8 @@ export default function ClientFormModal({
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
-  function update<K extends keyof FormState>(key: K, value: FormState[K]) {
-    setForm((prev) => ({ ...prev, [key]: value }));
-  }
-
   function addExternalLinkRow(kind: 'link' | 'subscription') {
-    setForm((prev) => ({
-      ...prev,
-      externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
-    }));
-  }
-
-  function updateExternalLinkRow(key: number, value: string) {
-    setForm((prev) => ({
-      ...prev,
-      externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
-    }));
-  }
-
-  function removeExternalLinkRow(key: number) {
-    setForm((prev) => ({
-      ...prev,
-      externalLinks: prev.externalLinks.filter((r) => r.key !== key),
-    }));
+    appendExternalLink({ kind, value: '' });
   }
 
   useEffect(() => {
@@ -231,8 +209,8 @@ export default function ClientFormModal({
 
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
-      const next: FormState = {
-        ...emptyForm(),
+      const seed: Values = {
+        ...EMPTY,
         email: client.email || '',
         subId: client.subId || '',
         uuid: client.uuid || '',
@@ -258,20 +236,20 @@ export default function ClientFormModal({
         adTag: client.adTag || '',
       };
       if (et < 0) {
-        next.delayedStart = true;
-        next.delayedDays = Math.round(et / -86400000);
-        next.expiryDate = null;
+        seed.delayedStart = true;
+        seed.delayedDays = Math.round(et / -86400000);
+        seed.expiryDate = 0;
       } else {
-        next.delayedStart = false;
-        next.delayedDays = 0;
-        next.expiryDate = et > 0 ? dayjs(et) : null;
+        seed.delayedStart = false;
+        seed.delayedDays = 0;
+        seed.expiryDate = et > 0 ? et : 0;
       }
-      setForm(next);
+      methods.reset(seed);
       void loadIps();
     } else {
       const wgKeypair = Wireguard.generateKeypair();
-      setForm({
-        ...emptyForm(),
+      methods.reset({
+        ...EMPTY,
         email: RandomUtil.randomLowerAndNum(10),
         uuid: RandomUtil.randomUUID(),
         subId: RandomUtil.randomLowerAndNum(16),
@@ -326,105 +304,112 @@ export default function ClientFormModal({
   }, [inbounds]);
 
   const mtprotoDomain = useMemo(() => {
-    for (const id of form.inboundIds || []) {
+    for (const id of inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
       if (ib?.protocol === 'mtproto' && ib.mtprotoDomain) return ib.mtprotoDomain;
     }
     return 'www.cloudflare.com';
-  }, [form.inboundIds, inbounds]);
+  }, [inboundIds, inbounds]);
 
   const ss2022Method = useMemo(() => {
-    for (const id of form.inboundIds || []) {
+    for (const id of inboundIds || []) {
       const ib = (inbounds || []).find((row) => row.id === id);
       const method = ib?.ssMethod;
       if (method && method.substring(0, 4) === '2022') return method;
     }
     return '';
-  }, [form.inboundIds, inbounds]);
+  }, [inboundIds, inbounds]);
 
   function regeneratePassword() {
-    update('password', ss2022Method
+    methods.setValue('password', ss2022Method
       ? RandomUtil.randomShadowsocksPassword(ss2022Method)
       : RandomUtil.randomLowerAndNum(16));
   }
 
   const showFlow = useMemo(
-    () => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
-    [form.inboundIds, flowCapableIds],
+    () => (inboundIds || []).some((id) => flowCapableIds.has(id)),
+    [inboundIds, flowCapableIds],
   );
 
   const showReverseTag = useMemo(
-    () => (form.inboundIds || []).some((id) => vlessLikeIds.has(id)),
-    [form.inboundIds, vlessLikeIds],
+    () => (inboundIds || []).some((id) => vlessLikeIds.has(id)),
+    [inboundIds, vlessLikeIds],
   );
 
   const showSecurity = useMemo(
-    () => (form.inboundIds || []).some((id) => vmessIds.has(id)),
-    [form.inboundIds, vmessIds],
+    () => (inboundIds || []).some((id) => vmessIds.has(id)),
+    [inboundIds, vmessIds],
   );
 
   const showWireguard = useMemo(
-    () => (form.inboundIds || []).some((id) => wireguardIds.has(id)),
-    [form.inboundIds, wireguardIds],
+    () => (inboundIds || []).some((id) => wireguardIds.has(id)),
+    [inboundIds, wireguardIds],
   );
 
   const showMtproto = useMemo(
-    () => (form.inboundIds || []).some((id) => mtprotoIds.has(id)),
-    [form.inboundIds, mtprotoIds],
+    () => (inboundIds || []).some((id) => mtprotoIds.has(id)),
+    [inboundIds, mtprotoIds],
   );
 
   function regenerateWireguardKeys() {
     const kp = Wireguard.generateKeypair();
-    setForm((prev) => ({ ...prev, wgPrivateKey: kp.privateKey, wgPublicKey: kp.publicKey }));
+    methods.setValue('wgPrivateKey', kp.privateKey);
+    methods.setValue('wgPublicKey', kp.publicKey);
   }
 
   function regenerateMtprotoSecret() {
-    update('secret', generateMtprotoSecret(mtprotoDomain));
+    methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
   }
 
   useEffect(() => {
-    if (!showFlow && form.flow) {
-
-      update('flow', '');
+    if (!showFlow && flow) {
+      methods.setValue('flow', '');
     }
-  }, [showFlow, form.flow]);
+  }, [showFlow, flow, methods]);
 
   useEffect(() => {
-    if (!showReverseTag && form.reverseTag) {
-
-      update('reverseTag', '');
+    if (!showReverseTag && reverseTag) {
+      methods.setValue('reverseTag', '');
     }
-  }, [showReverseTag, form.reverseTag]);
+  }, [showReverseTag, reverseTag, methods]);
 
   useEffect(() => {
     if (!ss2022Method) return;
-    setForm((prev) => (
-      RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
-        ? prev
-        : { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
-    ));
-  }, [ss2022Method]);
+    const current = methods.getValues('password');
+    if (!RandomUtil.isShadowsocks2022Password(current, ss2022Method)) {
+      methods.setValue('password', RandomUtil.randomShadowsocksPassword(ss2022Method));
+    }
+  }, [ss2022Method, methods]);
 
   useEffect(() => {
-    if (showMtproto && !form.secret) {
-      update('secret', generateMtprotoSecret(mtprotoDomain));
+    if (showMtproto && !secret) {
+      methods.setValue('secret', generateMtprotoSecret(mtprotoDomain));
     }
-  }, [showMtproto, form.secret, mtprotoDomain]);
+  }, [showMtproto, secret, mtprotoDomain, methods]);
 
   const inboundOptions = useMemo(
     () => (inbounds || [])
       .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
-      .filter((ib) => ib.enable || (form.inboundIds || []).includes(ib.id))
+      .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
       .map((ib) => ({
         label: formatInboundLabel(ib.tag, ib.remark),
         value: ib.id,
         title: formatInboundLabel(ib.tag, ib.remark),
       })),
-    [inbounds, form.inboundIds],
+    [inbounds, inboundIds],
+  );
+
+  const expiryDayjs = useMemo<Dayjs | null>(
+    () => (expiryDate > 0 ? dayjs(expiryDate) : null),
+    [expiryDate],
   );
 
-  const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
-  const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
+  const linkRows = externalLinkFields
+    .map((field, index) => ({ field, index }))
+    .filter((row) => row.field.kind === 'link');
+  const subscriptionRows = externalLinkFields
+    .map((field, index) => ({ field, index }))
+    .filter((row) => row.field.kind === 'subscription');
 
   async function loadIps() {
     if (!isEdit || !client?.email) return;
@@ -474,64 +459,65 @@ export default function ClientFormModal({
   }
 
   async function onSubmit() {
+    const values = methods.getValues();
     const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
     const validated = schema.safeParse({
-      email: form.email,
-      subId: form.subId,
-      uuid: form.uuid,
-      password: form.password,
-      auth: form.auth,
-      flow: form.flow,
-      security: form.security,
-      reverseTag: form.reverseTag,
-      totalGB: form.totalGB,
-      delayedStart: form.delayedStart,
-      delayedDays: form.delayedDays,
-      reset: form.reset,
-      limitIp: form.limitIp,
-      tgId: form.tgId,
-      group: form.group,
-      comment: form.comment,
-      enable: form.enable,
-      inboundIds: form.inboundIds,
+      email: values.email,
+      subId: values.subId,
+      uuid: values.uuid,
+      password: values.password,
+      auth: values.auth,
+      flow: values.flow,
+      security: values.security,
+      reverseTag: values.reverseTag,
+      totalGB: values.totalGB,
+      delayedStart: values.delayedStart,
+      delayedDays: values.delayedDays,
+      reset: values.reset,
+      limitIp: values.limitIp,
+      tgId: values.tgId,
+      group: values.group,
+      comment: values.comment,
+      enable: values.enable,
+      inboundIds: values.inboundIds,
     });
     if (!validated.success) {
       const issue = validated.error.issues[0];
       messageApi.error(t(issue?.message ?? 'somethingWentWrong'));
       return;
     }
-    const expiryTime = form.delayedStart
-      ? -86400000 * (Number(form.delayedDays) || 0)
-      : (form.expiryDate ? form.expiryDate.valueOf() : 0);
+    const expiryTime = values.delayedStart
+      ? -86400000 * (Number(values.delayedDays) || 0)
+      : (values.expiryDate || 0);
     const clientPayload: Record<string, unknown> = {
-      email: form.email.trim(),
-      subId: form.subId,
-      id: form.uuid,
-      password: form.password,
-      auth: form.auth,
-      flow: showFlow ? (form.flow || '') : '',
-      security: showSecurity ? (form.security || 'auto') : 'auto',
-      totalGB: gbToBytes(form.totalGB),
+      email: values.email.trim(),
+      subId: values.subId,
+      id: values.uuid,
+      password: values.password,
+      auth: values.auth,
+      flow: showFlow ? (values.flow || '') : '',
+      security: showSecurity ? (values.security || 'auto') : 'auto',
+      totalGB: gbToBytes(values.totalGB),
       expiryTime,
-      reset: Number(form.reset) || 0,
-      limitIp: Number(form.limitIp) || 0,
-      tgId: Number(form.tgId) || 0,
-      group: form.group,
-      comment: form.comment,
-      enable: !!form.enable,
+      reset: Number(values.reset) || 0,
+      limitIp: Number(values.limitIp) || 0,
+      tgId: Number(values.tgId) || 0,
+      group: values.group,
+      comment: values.comment,
+      enable: !!values.enable,
     };
-    const reverseTag = showReverseTag ? (form.reverseTag || '').trim() : '';
-    if (reverseTag) {
-      clientPayload.reverse = { tag: reverseTag };
+    const reverseTagValue = showReverseTag ? (values.reverseTag || '').trim() : '';
+    if (reverseTagValue) {
+      clientPayload.reverse = { tag: reverseTagValue };
     }
 
     if (showWireguard) {
-      clientPayload.privateKey = form.wgPrivateKey;
-      clientPayload.publicKey = form.wgPublicKey;
-      if (form.wgPreSharedKey) {
-        clientPayload.preSharedKey = form.wgPreSharedKey;
+      clientPayload.privateKey = values.wgPrivateKey;
+      clientPayload.publicKey = values.wgPublicKey;
+      if (values.wgPreSharedKey) {
+        clientPayload.preSharedKey = values.wgPreSharedKey;
       }
-      const allowedIPs = form.wgAllowedIPs
+      const allowedIPs = values.wgAllowedIPs
         .split(',')
         .map((s) => s.trim())
         .filter((s) => s !== '');
@@ -541,16 +527,16 @@ export default function ClientFormModal({
     }
 
     if (showMtproto) {
-      const adTag = form.adTag.trim();
+      const adTag = values.adTag.trim();
       if (adTag !== '' && !/^[0-9a-fA-F]{32}$/.test(adTag)) {
         messageApi.error(t('pages.inbounds.form.mtgAdTagInvalid'));
         return;
       }
-      clientPayload.secret = form.secret;
+      clientPayload.secret = values.secret;
       clientPayload.adTag = adTag;
     }
 
-    const externalLinks: ExternalLinkInput[] = form.externalLinks
+    const externalLinks: ExternalLinkInput[] = values.externalLinks
       .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
       .filter((r) => r.value !== '');
 
@@ -559,7 +545,7 @@ export default function ClientFormModal({
       let msg;
       if (isEdit && client) {
         const original = new Set(attachedIds || []);
-        const next = new Set(form.inboundIds || []);
+        const next = new Set(values.inboundIds || []);
         const toAttach = [...next].filter((id) => !original.has(id));
         const toDetach = [...original].filter((id) => !next.has(id));
         msg = await save(clientPayload, {
@@ -571,7 +557,7 @@ export default function ClientFormModal({
         });
       } else {
         msg = await save(
-          { client: clientPayload, inboundIds: form.inboundIds },
+          { client: clientPayload, inboundIds: values.inboundIds },
           { isEdit: false, email: clientPayload.email as string, externalLinks },
         );
       }
@@ -618,335 +604,344 @@ export default function ClientFormModal({
           </div>
         }
       >
-        <Form layout="vertical">
-          <Tabs
-            defaultActiveKey="basic"
-            items={[
-              {
-                key: 'basic',
-                label: t('pages.clients.tabBasics'),
-                children: (
-                  <>
-                    <Row gutter={16}>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.email')} required>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input
-                              value={form.email}
-                              placeholder={t('pages.clients.email')}
-                              style={{ flex: 1 }}
-                              onChange={(e) => update('email', e.target.value)}
-                            />
-                            {!isEdit && (
-                              <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
-                            )}
-                          </Space.Compact>
-                        </Form.Item>
-                      </Col>
-                      <Col xs={24} md={6}>
-                        <Form.Item label={t('pages.clients.totalGB')} tooltip={t('pages.clients.totalGBDesc')}>
-                          <InputNumber value={form.totalGB} min={0} step={1} style={{ width: '100%' }}
-                            onChange={(v) => update('totalGB', Number(v) || 0)} />
-                        </Form.Item>
-                      </Col>
-                      <Col xs={24} md={6}>
-                        <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
-                          <Tooltip title={limitIpNotice || undefined}>
-                            <span style={{ display: 'flex', width: '100%' }}>
-                              <Space.Compact style={{ display: 'flex', flex: 1 }}>
-                                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
-                                  style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
-                                  onChange={(v) => update('limitIp', Number(v) || 0)} />
-                                {isEdit && (
-                                  <Tooltip title={t('pages.clients.ipLog')}>
-                                    <Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                                      {clientIps.length > 0 ? clientIps.length : ''}
-                                    </Button>
-                                  </Tooltip>
-                                )}
-                              </Space.Compact>
-                            </span>
-                          </Tooltip>
-                        </Form.Item>
-                      </Col>
-                    </Row>
-
-                    <Row gutter={16}>
-                      <Col xs={24} md={12}>
-                        {form.delayedStart ? (
-                          <Form.Item label={t('pages.clients.expireDays')}>
-                            <InputNumber value={form.delayedDays} min={0} style={{ width: '100%' }}
-                              onChange={(v) => update('delayedDays', Number(v) || 0)} />
+        <FormProvider {...methods}>
+          <Form layout="vertical">
+            <Tabs
+              defaultActiveKey="basic"
+              items={[
+                {
+                  key: 'basic',
+                  label: t('pages.clients.tabBasics'),
+                  children: (
+                    <>
+                      <Row gutter={16}>
+                        <Col xs={24} md={12}>
+                          <Form.Item label={t('pages.clients.email')} required>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <Input
+                                value={email}
+                                placeholder={t('pages.clients.email')}
+                                style={{ flex: 1 }}
+                                onChange={(e) => methods.setValue('email', e.target.value)}
+                              />
+                              {!isEdit && (
+                                <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('email', RandomUtil.randomLowerAndNum(12))} />
+                              )}
+                            </Space.Compact>
                           </Form.Item>
-                        ) : (
-                          <Form.Item label={t('pages.clients.expiryTime')}>
-                            <DateTimePicker
-                              value={form.expiryDate}
-                              onChange={(d) => update('expiryDate', d || null)}
-                            />
+                        </Col>
+                        <Col xs={24} md={6}>
+                          <FormField
+                            name="totalGB"
+                            label={t('pages.clients.totalGB')}
+                            tooltip={t('pages.clients.totalGBDesc')}
+                            transform={{ output: (v) => Number(v) || 0 }}
+                          >
+                            <InputNumber min={0} step={1} style={{ width: '100%' }} />
+                          </FormField>
+                        </Col>
+                        <Col xs={24} md={6}>
+                          <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
+                            <Tooltip title={limitIpNotice || undefined}>
+                              <span style={{ display: 'flex', width: '100%' }}>
+                                <Space.Compact style={{ display: 'flex', flex: 1 }}>
+                                  <InputNumber value={limitIp} min={0} disabled={limitIpDisabled}
+                                    style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
+                                    onChange={(v) => methods.setValue('limitIp', Number(v) || 0)} />
+                                  {isEdit && (
+                                    <Tooltip title={t('pages.clients.ipLog')}>
+                                      <Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                                        {clientIps.length > 0 ? clientIps.length : ''}
+                                      </Button>
+                                    </Tooltip>
+                                  )}
+                                </Space.Compact>
+                              </span>
+                            </Tooltip>
                           </Form.Item>
-                        )}
-                      </Col>
-                      <Col xs={12} md={6}>
-                        <Form.Item label={t('pages.clients.delayedStart')}>
-                          <Switch
-                            checked={form.delayedStart}
-                            onChange={(v) => {
-                              update('delayedStart', v);
-                              if (v) update('expiryDate', null);
-                              else update('delayedDays', 0);
-                            }}
-                          />
-                        </Form.Item>
-                      </Col>
-                      <Col xs={12} md={6}>
-                        <Form.Item
-                          label={t('pages.clients.renewDays')}
-                          tooltip={t('pages.clients.renewDesc')}
-                        >
-                          <InputNumber value={form.reset} min={0} style={{ width: '100%' }}
-                            onChange={(v) => update('reset', Number(v) || 0)} />
-                        </Form.Item>
-                      </Col>
-                    </Row>
-
-                    <Row gutter={16}>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.comment')}>
-                          <Input value={form.comment} onChange={(e) => update('comment', e.target.value)} />
-                        </Form.Item>
-                      </Col>
-                      <Col xs={24} md={12}>
-                        <Form.Item label={t('pages.clients.group')} tooltip={t('pages.clients.groupDesc')}>
-                          <AutoComplete
-                            value={form.group}
-                            placeholder={t('pages.clients.groupPlaceholder')}
-                            options={groups.map((g) => ({ value: g }))}
-                            onChange={(v) => update('group', v ?? '')}
-                            allowClear
-                          />
-                        </Form.Item>
-                      </Col>
-                    </Row>
+                        </Col>
+                      </Row>
 
-                    {(tgBotEnable || showReverseTag) && (
                       <Row gutter={16}>
-                        {tgBotEnable && (
-                          <Col xs={24} md={12}>
-                            <Form.Item label={t('pages.clients.telegramId')}>
-                              <InputNumber value={form.tgId} min={0} controls={false}
-                                placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }}
-                                onChange={(v) => update('tgId', Number(v) || 0)} />
+                        <Col xs={24} md={12}>
+                          {delayedStart ? (
+                            <FormField
+                              name="delayedDays"
+                              label={t('pages.clients.expireDays')}
+                              transform={{ output: (v) => Number(v) || 0 }}
+                            >
+                              <InputNumber min={0} style={{ width: '100%' }} />
+                            </FormField>
+                          ) : (
+                            <Form.Item label={t('pages.clients.expiryTime')}>
+                              <DateTimePicker
+                                value={expiryDayjs}
+                                onChange={(d) => methods.setValue('expiryDate', d ? d.valueOf() : 0)}
+                              />
                             </Form.Item>
-                          </Col>
-                        )}
-                        {showReverseTag && (
-                          <Col xs={24} md={12}>
-                            <Form.Item label={t('pages.clients.reverseTag')}>
-                              <Input value={form.reverseTag} placeholder={t('pages.clients.reverseTagPlaceholder')}
-                                onChange={(e) => update('reverseTag', e.target.value)} />
-                            </Form.Item>
-                          </Col>
-                        )}
+                          )}
+                        </Col>
+                        <Col xs={12} md={6}>
+                          <Form.Item label={t('pages.clients.delayedStart')}>
+                            <Switch
+                              checked={delayedStart}
+                              onChange={(v) => {
+                                methods.setValue('delayedStart', v);
+                                if (v) methods.setValue('expiryDate', 0);
+                                else methods.setValue('delayedDays', 0);
+                              }}
+                            />
+                          </Form.Item>
+                        </Col>
+                        <Col xs={12} md={6}>
+                          <FormField
+                            name="reset"
+                            label={t('pages.clients.renewDays')}
+                            tooltip={t('pages.clients.renewDesc')}
+                            transform={{ output: (v) => Number(v) || 0 }}
+                          >
+                            <InputNumber min={0} style={{ width: '100%' }} />
+                          </FormField>
+                        </Col>
                       </Row>
-                    )}
-
-                    <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
-                      <SelectAllClearButtons
-                        options={inboundOptions}
-                        value={form.inboundIds}
-                        onChange={(v) => update('inboundIds', v)}
-                      />
-                      <Select
-                        mode="multiple"
-                        value={form.inboundIds}
-                        onChange={(v) => update('inboundIds', v)}
-                        options={inboundOptions}
-                        placeholder={t('pages.clients.selectInbound')}
-                        maxTagCount="responsive"
-                        placement="topLeft"
-                        listHeight={220}
-                        showSearch={{
-                          filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
-                        }}
-                      />
-                    </Form.Item>
-
-                    <Form.Item>
-                      <Switch aria-label={t('enable')} checked={form.enable} onChange={(v) => update('enable', v)} />
-                      <span style={{ marginLeft: 8 }}>{t('enable')}</span>
-                    </Form.Item>
-                  </>
-                ),
-              },
-              {
-                key: 'config',
-                label: t('pages.clients.tabCredentials'),
-                children: (
-                  <>
-                    <Form.Item label={t('pages.clients.uuid')}>
-                      <Space.Compact style={{ display: 'flex' }}>
-                        <Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
-                        <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
-                      </Space.Compact>
-                    </Form.Item>
-
-                    <Form.Item label={t('pages.clients.password')} tooltip={t('pages.clients.passwordDesc')}>
-                      <Space.Compact style={{ display: 'flex' }}>
-                        <Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
-                        <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
-                      </Space.Compact>
-                    </Form.Item>
-
-                    <Form.Item label={t('pages.clients.subId')}>
-                      <Space.Compact style={{ display: 'flex' }}>
-                        <Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
-                        <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
-                      </Space.Compact>
-                    </Form.Item>
-
-                    <Form.Item label={t('pages.clients.hysteriaAuth')} tooltip={t('pages.clients.hysteriaAuthDesc')}>
-                      <Space.Compact style={{ display: 'flex' }}>
-                        <Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
-                        <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
-                      </Space.Compact>
-                    </Form.Item>
-
-                    {showFlow && (
-                      <Form.Item label={t('pages.clients.flow')}>
-                        <Select
-                          value={form.flow}
-                          onChange={(v) => update('flow', v)}
-                          options={[
-                            { value: '', label: t('none') },
-                            ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
-                          ]}
+
+                      <Row gutter={16}>
+                        <Col xs={24} md={12}>
+                          <FormField name="comment" label={t('pages.clients.comment')}>
+                            <Input />
+                          </FormField>
+                        </Col>
+                        <Col xs={24} md={12}>
+                          <FormField
+                            name="group"
+                            label={t('pages.clients.group')}
+                            tooltip={t('pages.clients.groupDesc')}
+                            transform={{ output: (v) => v ?? '' }}
+                          >
+                            <AutoComplete
+                              placeholder={t('pages.clients.groupPlaceholder')}
+                              options={groups.map((g) => ({ value: g }))}
+                              allowClear
+                            />
+                          </FormField>
+                        </Col>
+                      </Row>
+
+                      {(tgBotEnable || showReverseTag) && (
+                        <Row gutter={16}>
+                          {tgBotEnable && (
+                            <Col xs={24} md={12}>
+                              <FormField
+                                name="tgId"
+                                label={t('pages.clients.telegramId')}
+                                transform={{ output: (v) => Number(v) || 0 }}
+                              >
+                                <InputNumber min={0} controls={false}
+                                  placeholder={t('pages.clients.telegramIdPlaceholder')} style={{ width: '100%' }} />
+                              </FormField>
+                            </Col>
+                          )}
+                          {showReverseTag && (
+                            <Col xs={24} md={12}>
+                              <FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
+                                <Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
+                              </FormField>
+                            </Col>
+                          )}
+                        </Row>
+                      )}
+
+                      <Form.Item label={t('pages.clients.attachedInbounds')} required={!isEdit}>
+                        <SelectAllClearButtons
+                          options={inboundOptions}
+                          value={inboundIds}
+                          onChange={(v) => methods.setValue('inboundIds', v)}
                         />
-                      </Form.Item>
-                    )}
-                    {showSecurity && (
-                      <Form.Item label={t('pages.clients.vmessSecurity')}>
                         <Select
-                          value={form.security}
-                          onChange={(v) => update('security', v)}
-                          options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
+                          mode="multiple"
+                          value={inboundIds}
+                          onChange={(v) => methods.setValue('inboundIds', v)}
+                          options={inboundOptions}
+                          placeholder={t('pages.clients.selectInbound')}
+                          maxTagCount="responsive"
+                          placement="topLeft"
+                          listHeight={220}
+                          showSearch={{
+                            filterOption: (input, option) => ((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
+                          }}
                         />
                       </Form.Item>
-                    )}
-                    {showWireguard && (
-                      <>
-                        <Form.Item label={t('pages.clients.wireguardPrivateKey')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input
-                              value={form.wgPrivateKey}
-                              style={{ flex: 1 }}
-                              onChange={(e) => {
-                                const priv = e.target.value;
-                                update('wgPrivateKey', priv);
-                                update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
-                              }}
-                            />
-                            <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
-                          </Space.Compact>
-                        </Form.Item>
-                        <Form.Item label={t('pages.clients.wireguardPublicKey')}>
-                          <Input value={form.wgPublicKey} disabled />
-                        </Form.Item>
-                        <Form.Item label={t('pages.clients.wireguardPreSharedKey')}>
-                          <Input
-                            value={form.wgPreSharedKey}
-                            onChange={(e) => update('wgPreSharedKey', e.target.value)}
-                          />
-                        </Form.Item>
-                        <Form.Item
-                          label={t('pages.clients.wireguardAllowedIPs')}
-                          extra={t('pages.clients.wireguardAllowedIPsHint')}
-                        >
-                          <Input
-                            value={form.wgAllowedIPs}
-                            placeholder="10.0.0.2/32"
-                            onChange={(e) => update('wgAllowedIPs', e.target.value)}
-                          />
-                        </Form.Item>
-                      </>
-                    )}
-                    {showMtproto && (
-                      <>
-                        <Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <Input value={form.secret} style={{ flex: 1 }} onChange={(e) => update('secret', e.target.value)} />
-                            <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
-                          </Space.Compact>
-                        </Form.Item>
-                        <Form.Item label={t('pages.clients.mtprotoAdTag')} extra={t('pages.clients.mtprotoAdTagHint')}>
-                          <Input
-                            value={form.adTag}
-                            allowClear
-                            placeholder="0123456789abcdef0123456789abcdef"
-                            onChange={(e) => update('adTag', e.target.value)}
-                          />
-                        </Form.Item>
-                      </>
-                    )}
-                  </>
-                ),
-              },
-              {
-                key: 'links',
-                label: t('pages.clients.tabLinks'),
-                children: (
-                  <>
-                    <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
-                      {t('pages.clients.linksHint')}
-                    </Typography.Paragraph>
-
-                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
-                      {t('pages.clients.addExternalLink')}
-                    </Button>
-                    <div style={{ marginTop: 12, marginBottom: 24 }}>
-                      {linkRows.length === 0 ? (
-                        <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
-                      ) : linkRows.map((row) => (
-                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                          <Input
-                            value={row.value}
-                            aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
-                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
-                            placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+
+                      <Form.Item>
+                        <Switch aria-label={t('enable')} checked={enable} onChange={(v) => methods.setValue('enable', v)} />
+                        <span style={{ marginLeft: 8 }}>{t('enable')}</span>
+                      </Form.Item>
+                    </>
+                  ),
+                },
+                {
+                  key: 'config',
+                  label: t('pages.clients.tabCredentials'),
+                  children: (
+                    <>
+                      <Form.Item label={t('pages.clients.uuid')}>
+                        <Space.Compact style={{ display: 'flex' }}>
+                          <Input value={uuid} style={{ flex: 1 }} onChange={(e) => methods.setValue('uuid', e.target.value)} />
+                          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('uuid', RandomUtil.randomUUID())} />
+                        </Space.Compact>
+                      </Form.Item>
+
+                      <Form.Item label={t('pages.clients.password')} tooltip={t('pages.clients.passwordDesc')}>
+                        <Space.Compact style={{ display: 'flex' }}>
+                          <Input value={password} style={{ flex: 1 }} onChange={(e) => methods.setValue('password', e.target.value)} />
+                          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
+                        </Space.Compact>
+                      </Form.Item>
+
+                      <Form.Item label={t('pages.clients.subId')}>
+                        <Space.Compact style={{ display: 'flex' }}>
+                          <Input value={subId} style={{ flex: 1 }} onChange={(e) => methods.setValue('subId', e.target.value)} />
+                          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('subId', RandomUtil.randomLowerAndNum(16))} />
+                        </Space.Compact>
+                      </Form.Item>
+
+                      <Form.Item label={t('pages.clients.hysteriaAuth')} tooltip={t('pages.clients.hysteriaAuthDesc')}>
+                        <Space.Compact style={{ display: 'flex' }}>
+                          <Input value={auth} style={{ flex: 1 }} onChange={(e) => methods.setValue('auth', e.target.value)} />
+                          <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => methods.setValue('auth', RandomUtil.randomLowerAndNum(16))} />
+                        </Space.Compact>
+                      </Form.Item>
+
+                      {showFlow && (
+                        <FormField name="flow" label={t('pages.clients.flow')}>
+                          <Select
+                            options={[
+                              { value: '', label: t('none') },
+                              ...FLOW_OPTIONS.map((k) => ({ value: k, label: k })),
+                            ]}
                           />
-                          <Tooltip title={t('delete')}>
-                            <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
-                          </Tooltip>
-                        </div>
-                      ))}
-                    </div>
-
-                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
-                      {t('pages.clients.addExternalSubscription')}
-                    </Button>
-                    <div style={{ marginTop: 12 }}>
-                      {subscriptionRows.length === 0 ? (
-                        <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
-                      ) : subscriptionRows.map((row) => (
-                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
-                          <Input
-                            value={row.value}
-                            aria-label="https://provider.example/sub/…"
-                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
-                            placeholder="https://provider.example/sub/…"
+                        </FormField>
+                      )}
+                      {showSecurity && (
+                        <FormField name="security" label={t('pages.clients.vmessSecurity')}>
+                          <Select
+                            options={VMESS_SECURITY_OPTIONS.map((k) => ({ value: k, label: k }))}
                           />
-                          <Tooltip title={t('delete')}>
-                            <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
-                          </Tooltip>
-                        </div>
-                      ))}
-                    </div>
-                  </>
-                ),
-              },
-            ]}
-          />
-        </Form>
+                        </FormField>
+                      )}
+                      {showWireguard && (
+                        <>
+                          <Form.Item label={t('pages.clients.wireguardPrivateKey')}>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <Input
+                                value={wgPrivateKey}
+                                style={{ flex: 1 }}
+                                onChange={(e) => {
+                                  const priv = e.target.value;
+                                  methods.setValue('wgPrivateKey', priv);
+                                  methods.setValue('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
+                                }}
+                              />
+                              <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
+                            </Space.Compact>
+                          </Form.Item>
+                          <FormField name="wgPublicKey" label={t('pages.clients.wireguardPublicKey')}>
+                            <Input disabled />
+                          </FormField>
+                          <FormField name="wgPreSharedKey" label={t('pages.clients.wireguardPreSharedKey')}>
+                            <Input />
+                          </FormField>
+                          <FormField
+                            name="wgAllowedIPs"
+                            label={t('pages.clients.wireguardAllowedIPs')}
+                            extra={t('pages.clients.wireguardAllowedIPsHint')}
+                          >
+                            <Input placeholder="10.0.0.2/32" />
+                          </FormField>
+                        </>
+                      )}
+                      {showMtproto && (
+                        <>
+                          <Form.Item label={t('pages.clients.mtprotoSecret')} extra={t('pages.clients.mtprotoSecretHint')}>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <Input value={secret} style={{ flex: 1 }} onChange={(e) => methods.setValue('secret', e.target.value)} />
+                              <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateMtprotoSecret} />
+                            </Space.Compact>
+                          </Form.Item>
+                          <FormField
+                            name="adTag"
+                            label={t('pages.clients.mtprotoAdTag')}
+                            extra={t('pages.clients.mtprotoAdTagHint')}
+                          >
+                            <Input
+                              allowClear
+                              placeholder="0123456789abcdef0123456789abcdef"
+                            />
+                          </FormField>
+                        </>
+                      )}
+                    </>
+                  ),
+                },
+                {
+                  key: 'links',
+                  label: t('pages.clients.tabLinks'),
+                  children: (
+                    <>
+                      <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
+                        {t('pages.clients.linksHint')}
+                      </Typography.Paragraph>
+
+                      <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
+                        {t('pages.clients.addExternalLink')}
+                      </Button>
+                      <div style={{ marginTop: 12, marginBottom: 24 }}>
+                        {linkRows.length === 0 ? (
+                          <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
+                        ) : linkRows.map(({ field, index }) => (
+                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                            <FormField name={`externalLinks.${index}.value`} noStyle>
+                              <Input
+                                style={{ flex: 1 }}
+                                aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                                placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                              />
+                            </FormField>
+                            <Tooltip title={t('delete')}>
+                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                            </Tooltip>
+                          </div>
+                        ))}
+                      </div>
+
+                      <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
+                        {t('pages.clients.addExternalSubscription')}
+                      </Button>
+                      <div style={{ marginTop: 12 }}>
+                        {subscriptionRows.length === 0 ? (
+                          <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
+                        ) : subscriptionRows.map(({ field, index }) => (
+                          <div key={field.id} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                            <FormField name={`externalLinks.${index}.value`} noStyle>
+                              <Input
+                                style={{ flex: 1 }}
+                                aria-label="https://provider.example/sub/…"
+                                placeholder="https://provider.example/sub/…"
+                              />
+                            </FormField>
+                            <Tooltip title={t('delete')}>
+                              <Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLink(index)} />
+                            </Tooltip>
+                          </div>
+                        ))}
+                      </div>
+                    </>
+                  ),
+                },
+              ]}
+            />
+          </Form>
+        </FormProvider>
       </Modal>
 
       <Modal

+ 224 - 207
frontend/src/pages/hosts/HostFormModal.tsx

@@ -11,18 +11,22 @@ import {
   DeploymentUnitOutlined,
   RocketOutlined,
 } from '@ant-design/icons';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import type { HostRecord } from '@/api/queries/useHostsQuery';
-import type { HostFormValues } from '@/schemas/api/host';
+import { HostFormSchema, type HostFormValues } from '@/schemas/api/host';
 import type { InboundOption } from '@/schemas/client';
 import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
 
-// inboundId is optional in the form so a new host starts unselected (the Select
-// shows its placeholder instead of 0); the required rule enforces it on submit.
+/*
+ * inboundId is optional in the form so a new host starts unselected (the Select
+ * shows its placeholder instead of 0); the required rule enforces it on submit.
+ */
 type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
 
 interface HostFormModalProps {
@@ -74,19 +78,21 @@ function defaultsFor(host: HostRecord | null): FormShape {
 export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
   const { t } = useTranslation();
   const { isMobile } = useMediaQuery();
-  const [form] = Form.useForm<FormShape>();
+  const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
 
-  // Drive conditional field visibility off the selected security, like the
-  // legacy externalProxy form: same/none inherit fully and hide every TLS/cert
-  // field; reality shows only the reality-relevant subset (its keys are
-  // inherited from the inbound); tls shows the full TLS override set.
-  const security = (Form.useWatch('security', form) ?? 'same') as string;
+  /*
+   * Drive conditional field visibility off the selected security, like the
+   * legacy externalProxy form: same/none inherit fully and hide every TLS/cert
+   * field; reality shows only the reality-relevant subset (its keys are
+   * inherited from the inbound); tls shows the full TLS override set.
+   */
+  const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
   const showTls = security === 'tls' || security === 'reality';
   const showTlsExtras = security === 'tls';
 
   useEffect(() => {
-    if (open) form.setFieldsValue(defaultsFor(host));
-  }, [open, host, form]);
+    if (open) methods.reset(defaultsFor(host));
+  }, [open, host, methods]);
 
   const { nodes } = useNodesQuery();
 
@@ -108,13 +114,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
   const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
   const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
 
-  const onOk = async () => {
-    let values: FormShape;
-    try {
-      values = await form.validateFields();
-    } catch {
-      return;
-    }
+  const onFinish = async (values: FormShape) => {
     const { enable, ...rest } = values;
     const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
     const res = await save(payload);
@@ -130,7 +130,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
     <Modal
       open={open}
       title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
-      onOk={onOk}
+      onOk={methods.handleSubmit(onFinish)}
       onCancel={() => onOpenChange(false)}
       okText={t('save')}
       cancelText={t('cancel')}
@@ -138,198 +138,215 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
       width={isMobile ? '95vw' : 760}
       styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
     >
-      <Form
-        form={form}
-        colon={false}
-        labelCol={{ sm: { span: 8 } }}
-        wrapperCol={{ sm: { span: 14 } }}
-        labelWrap
-        initialValues={defaultsFor(host)}
-        preserve={false}
-      >
-        <Tabs
-          defaultActiveKey="basic"
-          items={[
-            {
-              key: 'basic',
-              forceRender: true,
-              label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
-              children: (
-                <>
-                  <Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
-                    <Input maxLength={256} />
-                  </Form.Item>
-                  <Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
-                    <Input maxLength={64} />
-                  </Form.Item>
-                  <Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
-                    <Select
-                      options={inboundSelectOptions}
-                      showSearch
-                      optionFilterProp="label"
-                      disabled={mode === 'edit'}
-                      placeholder={t('pages.hosts.selectInbound')}
-                    />
-                  </Form.Item>
-                  <Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
-                    <Input placeholder="cdn.example.com" />
-                  </Form.Item>
-                  <Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
-                    <InputNumber min={0} max={65535} />
-                  </Form.Item>
-                  <Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
-                    <Select mode="tags" allowClear tokenSeparators={[',']} />
-                  </Form.Item>
-                  <Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
-                    <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
-                  </Form.Item>
-                  <Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
-                    <Switch />
-                  </Form.Item>
-                </>
-              ),
-            },
-            {
-              key: 'security',
-              forceRender: true,
-              label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
-              children: (
-                <>
-                  <Form.Item name="security" label={t('pages.hosts.fields.security')}>
-                    <Select
-                      options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                  {showTls && (
-                    <>
-                      <Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
-                        <Input />
-                      </Form.Item>
-                      <Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
-                        <Switch />
-                      </Form.Item>
-                      <Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
-                        <Switch />
-                      </Form.Item>
-                      <Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
-                        <Select allowClear options={fpOptions} />
-                      </Form.Item>
-                    </>
-                  )}
-                  {showTlsExtras && (
-                    <>
-                      <Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
-                        <Select mode="multiple" allowClear options={alpnOptions} />
-                      </Form.Item>
-                      <Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
-                        <Select mode="tags" allowClear tokenSeparators={[',']} />
-                      </Form.Item>
-                      <Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
-                        <Input placeholder="example.com" />
-                      </Form.Item>
-                      <Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
-                        <Switch />
-                      </Form.Item>
-                      <Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
-                        <Input.TextArea rows={2} />
-                      </Form.Item>
-                    </>
-                  )}
-                </>
-              ),
-            },
-            {
-              key: 'advanced',
-              forceRender: true,
-              label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
-              children: (
-                <Tabs
-                  size="small"
-                  defaultActiveKey="adv-general"
-                  items={[
-                    {
-                      key: 'adv-general',
-                      forceRender: true,
-                      label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
-                      children: (
-                        <>
-                          <Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
-                            <Input />
-                          </Form.Item>
-                          <Form.Item name="path" label={t('pages.hosts.fields.path')}>
-                            <Input />
+      <FormProvider {...methods}>
+        <Form
+          colon={false}
+          labelCol={{ sm: { span: 8 } }}
+          wrapperCol={{ sm: { span: 14 } }}
+          labelWrap
+        >
+          <Tabs
+            defaultActiveKey="basic"
+            items={[
+              {
+                key: 'basic',
+                forceRender: true,
+                label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
+                children: (
+                  <>
+                    <FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.remark) }}>
+                      <Input maxLength={256} />
+                    </FormField>
+                    <FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
+                      <Input maxLength={64} />
+                    </FormField>
+                    <FormField name="inboundId" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(HostFormSchema.shape.inboundId) }}>
+                      <Select
+                        options={inboundSelectOptions}
+                        showSearch
+                        optionFilterProp="label"
+                        disabled={mode === 'edit'}
+                        placeholder={t('pages.hosts.selectInbound')}
+                      />
+                    </FormField>
+                    <FormField name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
+                      <Input placeholder="cdn.example.com" />
+                    </FormField>
+                    <FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
+                      <InputNumber min={0} max={65535} />
+                    </FormField>
+                    <FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
+                      <Select mode="tags" allowClear tokenSeparators={[',']} />
+                    </FormField>
+                    <FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
+                      <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
+                    </FormField>
+                    <FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
+                      <Switch />
+                    </FormField>
+                  </>
+                ),
+              },
+              {
+                key: 'security',
+                forceRender: true,
+                label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
+                children: (
+                  <>
+                    <FormField name="security" label={t('pages.hosts.fields.security')}>
+                      <Select
+                        options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
+                      />
+                    </FormField>
+                    {showTls && (
+                      <>
+                        <FormField name="sni" label={t('pages.hosts.fields.sni')}>
+                          <Input />
+                        </FormField>
+                        <FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
+                          <Switch />
+                        </FormField>
+                        <FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
+                          <Switch />
+                        </FormField>
+                        <FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
+                          <Select allowClear options={fpOptions} />
+                        </FormField>
+                      </>
+                    )}
+                    {showTlsExtras && (
+                      <>
+                        <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
+                          <Select mode="multiple" allowClear options={alpnOptions} />
+                        </FormField>
+                        <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
+                          <Select mode="tags" allowClear tokenSeparators={[',']} />
+                        </FormField>
+                        <FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
+                          <Input placeholder="example.com" />
+                        </FormField>
+                        <FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
+                          <Switch />
+                        </FormField>
+                        <FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
+                          <Input.TextArea rows={2} />
+                        </FormField>
+                      </>
+                    )}
+                  </>
+                ),
+              },
+              {
+                key: 'advanced',
+                forceRender: true,
+                label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
+                children: (
+                  <Tabs
+                    size="small"
+                    defaultActiveKey="adv-general"
+                    items={[
+                      {
+                        key: 'adv-general',
+                        forceRender: true,
+                        label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
+                        children: (
+                          <>
+                            <FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
+                              <Input />
+                            </FormField>
+                            <FormField name="path" label={t('pages.hosts.fields.path')}>
+                              <Input />
+                            </FormField>
+                            <FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
+                              <Input placeholder="443" />
+                            </FormField>
+                            <FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
+                              <Select
+                                mode="multiple"
+                                allowClear
+                                options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
+                              />
+                            </FormField>
+                          </>
+                        ),
+                      },
+                      {
+                        key: 'adv-mux',
+                        forceRender: true,
+                        label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
+                        children: (
+                          <Form.Item noStyle>
+                            <Controller
+                              control={methods.control}
+                              name="muxParams"
+                              render={({ field }) => (
+                                <HostMuxForm value={field.value} onChange={field.onChange} />
+                              )}
+                            />
                           </Form.Item>
-                          <Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
-                            <Input placeholder="443" />
+                        ),
+                      },
+                      {
+                        key: 'adv-sockopt',
+                        forceRender: true,
+                        label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
+                        children: (
+                          <Form.Item noStyle>
+                            <Controller
+                              control={methods.control}
+                              name="sockoptParams"
+                              render={({ field }) => (
+                                <HostSockoptForm value={field.value} onChange={field.onChange} />
+                              )}
+                            />
                           </Form.Item>
-                          <Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
-                            <Select
-                              mode="multiple"
-                              allowClear
-                              options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
+                        ),
+                      },
+                      {
+                        key: 'adv-finalmask',
+                        forceRender: true,
+                        label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
+                        children: (
+                          <Form.Item noStyle>
+                            <Controller
+                              control={methods.control}
+                              name="finalMask"
+                              render={({ field }) => (
+                                <HostFinalMaskForm value={field.value} onChange={field.onChange} />
+                              )}
                             />
                           </Form.Item>
-                        </>
-                      ),
-                    },
-                    {
-                      key: 'adv-mux',
-                      forceRender: true,
-                      label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
-                      children: (
-                        <Form.Item name="muxParams" noStyle>
-                          <HostMuxForm />
-                        </Form.Item>
-                      ),
-                    },
-                    {
-                      key: 'adv-sockopt',
-                      forceRender: true,
-                      label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
-                      children: (
-                        <Form.Item name="sockoptParams" noStyle>
-                          <HostSockoptForm />
-                        </Form.Item>
-                      ),
-                    },
-                    {
-                      key: 'adv-finalmask',
-                      forceRender: true,
-                      label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
-                      children: (
-                        <Form.Item name="finalMask" noStyle>
-                          <HostFinalMaskForm />
-                        </Form.Item>
-                      ),
-                    },
-                  ]}
-                />
-              ),
-            },
-            {
-              key: 'clash',
-              forceRender: true,
-              label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
-              children: (
-                <>
-                  <Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
-                    <Select
-                      allowClear
-                      options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                  <Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
-                    <Switch />
-                  </Form.Item>
-                </>
-              ),
-            },
-          ]}
-        />
-      </Form>
+                        ),
+                      },
+                    ]}
+                  />
+                ),
+              },
+              {
+                key: 'clash',
+                forceRender: true,
+                label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
+                children: (
+                  <>
+                    <FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
+                      <Select
+                        allowClear
+                        options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
+                      />
+                    </FormField>
+                    <FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
+                      <Switch />
+                    </FormField>
+                    <FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
+                      <Switch />
+                    </FormField>
+                  </>
+                ),
+              },
+            ]}
+          />
+        </Form>
+      </FormProvider>
     </Modal>
   );
 }

+ 9 - 7
frontend/src/pages/hosts/json-forms/HostMuxForm.tsx

@@ -3,10 +3,12 @@ import { MuxForm } from '@/pages/xray/outbounds/transport';
 import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
 import { serializeOverride } from './helpers';
 
-// Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
-// settings editor). Stored in the host's muxParams JSON string. Defaults match
-// the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
-// when the toggle is off, an explicit mux object when on.
+/*
+ * Mux override editor — reuses the outbound MuxForm (same fields as the sub-JSON
+ * settings editor). Stored in the host's muxParams JSON string. Defaults match
+ * the sub-JSON editor; the host stores '' (= inherit the inbound/global mux)
+ * when the toggle is off, an explicit mux object when on.
+ */
 const DEFAULT_MUX = { enabled: false, concurrency: 8, xudpConcurrency: 16, xudpProxyUDP443: 'reject' };
 
 export default function HostMuxForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
@@ -17,9 +19,9 @@ export default function HostMuxForm({ value, onChange }: { value?: string; onCha
       path={['mux']}
       defaultSubtree={DEFAULT_MUX}
       serialize={(mux) => ((mux as { enabled?: boolean } | undefined)?.enabled ? serializeOverride(mux) : '')}
-      // protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
-      // a host's mux override is protocol-agnostic and should always be editable.
-      render={(form) => <MuxForm form={form} protocol="vmess" network="tcp" />}
+      /* protocol/network are fixed only to satisfy MuxForm's isMuxAllowed gate;
+         a host's mux override is protocol-agnostic and should always be editable. */
+      render={() => <MuxForm protocol="vmess" network="tcp" />}
     />
   );
 }

+ 20 - 16
frontend/src/pages/hosts/json-forms/HostSockoptForm.tsx

@@ -4,16 +4,18 @@ import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
 import OutboundSubtreeJsonForm from './OutboundSubtreeJsonForm';
 import { serializeOverride } from './helpers';
 
-// Sockopt override editor — reuses the outbound SockoptForm (which carries its
-// own enable Switch and writes streamSettings.sockopt). Serialized to the host's
-// sockoptParams JSON string.
-//
-// A host is the client/dialer side, so the inbound-only sockopt keys are dropped
-// from the output. Verified against xray-core transport/internet/sockopt_*.go:
-// only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
-// are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
-// on the outbound/dialer socket, so they stay. The outbound form no longer shows
-// the inbound-only keys, but its default object still seeds them, so strip here.
+/*
+ * Sockopt override editor — reuses the outbound SockoptForm (which carries its
+ * own enable Switch and writes streamSettings.sockopt). Serialized to the host's
+ * sockoptParams JSON string.
+ *
+ * A host is the client/dialer side, so the inbound-only sockopt keys are dropped
+ * from the output. Verified against xray-core transport/internet/sockopt_*.go:
+ * only V6Only and the handler-level acceptProxyProtocol / trustedXForwardedFor
+ * are inbound-only — tproxy (IP_TRANSPARENT) and keepalive/interface ARE applied
+ * on the outbound/dialer socket, so they stay. The outbound form no longer shows
+ * the inbound-only keys, but its default object still seeds them, so strip here.
+ */
 const INBOUND_ONLY_SOCKOPT = ['acceptProxyProtocol', 'V6Only', 'trustedXForwardedFor'];
 
 function serializeClientSockopt(sockopt: unknown): string {
@@ -24,11 +26,13 @@ function serializeClientSockopt(sockopt: unknown): string {
 }
 
 export default function HostSockoptForm({ value, onChange }: { value?: string; onChange?: (next: string) => void }) {
-  // Populate the dialerProxy dropdown with the panel's outbound tags (a host can
-  // chain through one of the subscription's outbounds by tag). dialerProxy chains
-  // through a single outbound, so balancers (routing targets) are excluded — only
-  // the outbound group is used; blackhole is dropped too (chaining to it just
-  // drops the traffic).
+  /*
+   * Populate the dialerProxy dropdown with the panel's outbound tags (a host can
+   * chain through one of the subscription's outbounds by tag). dialerProxy chains
+   * through a single outbound, so balancers (routing targets) are excluded — only
+   * the outbound group is used; blackhole is dropped too (chaining to it just
+   * drops the traffic).
+   */
   const { data: tagGroups } = useOutboundTagGroups({ excludeBlackhole: true });
   const outboundTags = tagGroups?.outbounds ?? [];
   return (
@@ -37,7 +41,7 @@ export default function HostSockoptForm({ value, onChange }: { value?: string; o
       onChange={onChange}
       path={['streamSettings', 'sockopt']}
       serialize={serializeClientSockopt}
-      render={(form) => <SockoptForm form={form} outboundTags={outboundTags} />}
+      render={() => <SockoptForm outboundTags={outboundTags} />}
     />
   );
 }

+ 36 - 32
frontend/src/pages/hosts/json-forms/OutboundSubtreeJsonForm.tsx

@@ -1,29 +1,32 @@
 import { useEffect, useRef, useState, type ReactNode } from 'react';
-import { Form, type FormInstance } from 'antd';
-
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { Form } from 'antd';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
+import type { FieldValues } from 'react-hook-form';
 
 import { nestAtPath, parseJsonObject, serializeOverride } from './helpers';
 
 interface OutboundSubtreeJsonFormProps {
   value?: string;
   onChange?: (next: string) => void;
-  // Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux'].
+  /* Form path the inner form edits, e.g. ['streamSettings', 'sockopt'] or ['mux']. */
   path: (string | number)[];
-  // Renders the reused outbound form given this wrapper's own form instance.
-  render: (form: FormInstance<OutboundFormValues>) => ReactNode;
-  // Seeds the form when the stored value is empty, so toggling a section on
-  // pre-fills sensible defaults instead of blanks (used by Mux).
+  /* Renders the reused outbound form, which binds to this wrapper's RHF context. */
+  render: () => ReactNode;
+  /* Seeds the form when the stored value is empty, so toggling a section on
+     pre-fills sensible defaults instead of blanks (used by Mux). */
   defaultSubtree?: Record<string, unknown>;
-  // Turns the edited subtree into the stored JSON string (default: prune empties).
-  // Mux overrides this to store '' (= inherit) when its enable flag is off.
+  /* Turns the edited subtree into the stored JSON string (default: prune empties).
+     Mux overrides this to store '' (= inherit) when its enable flag is off. */
   serialize?: (subtree: unknown) => string;
 }
 
-// Hosts the reused outbound transport forms (which bind to fixed form paths)
-// inside an isolated antd Form, mirroring SubJsonFinalMaskForm: seed the form
-// from the JSON string, watch the edited subtree, and report a JSON string back
-// to the parent host form. component={false} avoids a nested <form> DOM node.
+/*
+ * Hosts the reused outbound transport forms (which bind to fixed RHF paths)
+ * inside an isolated RHF form, mirroring the sub-JSON adapters: seed the form
+ * from the JSON string, watch the edited subtree, and report a JSON string back
+ * to the parent host form. The antd Form is layout-only (component={false}
+ * avoids a nested <form> DOM node); data binding runs through the RHF provider.
+ */
 export default function OutboundSubtreeJsonForm({
   value = '',
   onChange,
@@ -32,37 +35,38 @@ export default function OutboundSubtreeJsonForm({
   defaultSubtree,
   serialize = serializeOverride,
 }: OutboundSubtreeJsonFormProps) {
-  const [form] = Form.useForm();
   const [initial] = useState<Record<string, unknown>>(() => {
     const parsed = parseJsonObject(value);
     return Object.keys(parsed).length ? parsed : (defaultSubtree ?? {});
   });
+  const [defaultValues] = useState<FieldValues>(() => {
+    const hasInitial = Object.keys(initial).length > 0;
+    return nestAtPath(path, hasInitial ? initial : undefined) as FieldValues;
+  });
+  const methods = useForm<FieldValues>({ defaultValues });
   const onChangeRef = useRef(onChange);
   onChangeRef.current = onChange;
 
-  const subtree = Form.useWatch(path, form);
+  const subtree = useWatch({ control: methods.control, name: path.join('.') });
 
   useEffect(() => {
     const next = serialize(subtree);
     if (next !== value) onChangeRef.current?.(next);
-    // serialize is logically stable; re-run only when the edited subtree changes.
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* serialize is logically stable; re-run only when the edited subtree changes. */
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [subtree, value]);
 
-  const hasInitial = Object.keys(initial).length > 0;
-  const initialValues = nestAtPath(path, hasInitial ? initial : undefined);
-
   return (
-    <Form
-      form={form}
-      component={false}
-      colon={false}
-      labelCol={{ sm: { span: 8 } }}
-      wrapperCol={{ sm: { span: 14 } }}
-      labelWrap
-      initialValues={initialValues}
-    >
-      {render(form as unknown as FormInstance<OutboundFormValues>)}
-    </Form>
+    <FormProvider {...methods}>
+      <Form
+        component={false}
+        colon={false}
+        labelCol={{ sm: { span: 8 } }}
+        wrapperCol={{ sm: { span: 14 } }}
+        labelWrap
+      >
+        {render()}
+      </Form>
+    </FormProvider>
   );
 }

+ 225 - 303
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -15,6 +15,7 @@ import {
   Tooltip,
   message,
 } from 'antd';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
 import type { RealityScanResult } from '@/generated/types';
@@ -36,7 +37,7 @@ import {
   InboundFormSchema,
   type InboundFormValues,
 } from '@/schemas/forms/inbound-form';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { Protocols } from '@/schemas/primitives';
 import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
@@ -50,7 +51,7 @@ import { GrpcStreamSettingsSchema } from '@/schemas/protocols/stream/grpc';
 import { HttpUpgradeStreamSettingsSchema } from '@/schemas/protocols/stream/httpupgrade';
 import { XHttpStreamSettingsSchema } from '@/schemas/protocols/stream/xhttp';
 import { DateTimePicker } from '@/components/form';
-import { FinalMaskForm } from '@/lib/xray/forms/transport';
+import { FinalMaskField } from '@/lib/xray/forms/fields';
 import './InboundFormModal.css';
 
 import { AdvancedAllEditor, AdvancedSliceEditor } from './advanced-editors';
@@ -85,7 +86,7 @@ import type { DBInbound } from '@/models/dbinbound';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 
 
-// Render a field label with a hover tooltip icon instead of an `extra` help line below.
+/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
 const labelWithHint = (label: string, hint: string) => (
   <span>
     {label}
@@ -162,6 +163,25 @@ function buildAddModeValues(): InboundFormValues {
   });
 }
 
+/*
+ * Switching `network` swaps which per-network key (tcpSettings, wsSettings,
+ * grpcSettings, ...) appears on the wire. Seed each network's blob with its
+ * Zod schema defaults so every field inside the network sub-form has a
+ * defined starting value (KCP needs MTU=1350 etc., XHTTP needs the ""
+ * sentinels so the "Default" option shows instead of blank).
+ */
+function newStreamSlice(n: string): Record<string, unknown> {
+  switch (n) {
+    case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
+    case 'kcp': return KcpStreamSettingsSchema.parse({});
+    case 'ws': return WsStreamSettingsSchema.parse({});
+    case 'grpc': return GrpcStreamSettingsSchema.parse({});
+    case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
+    case 'xhttp': return XHttpStreamSettingsSchema.parse({});
+    default: return {};
+  }
+}
+
 export default function InboundFormModal({
   open,
   onClose,
@@ -174,7 +194,10 @@ export default function InboundFormModal({
 }: InboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const [form] = Form.useForm<InboundFormValues>();
+  const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
+  const setV = methods.setValue as unknown as (name: string, value: unknown) => void;
+  const getV = methods.getValues as unknown as (name?: string) => unknown;
+  const control = methods.control;
   const [saving, setSaving] = useState(false);
   const [scanning, setScanning] = useState(false);
   const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
@@ -191,41 +214,47 @@ export default function InboundFormModal({
   } = useInboundFallbacks(dbInbound, dbInbounds);
 
   const selectableNodes = (availableNodes || []).filter((n) => n.enable);
-  const protocol = (Form.useWatch('protocol', form) ?? '') as string;
+  const protocol = (useWatch({ control, name: 'protocol' }) ?? '') as string;
   const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol);
-  // The `node` share-address strategy only means something when the inbound can
-  // actually live on a node — otherwise the node address it would resolve to is
-  // always empty. Offer it only then; `listen`/`custom` work for local inbounds.
+  /*
+   * The `node` share-address strategy only means something when the inbound can
+   * actually live on a node — otherwise the node address it would resolve to is
+   * always empty. Offer it only then; `listen`/`custom` work for local inbounds.
+   */
   const nodeShareOptionAvailable = selectableNodes.length > 0 && isNodeEligible;
-  const vlessEncryption = Form.useWatch(['settings', 'encryption'], form) ?? '';
-  const ssMethod = Form.useWatch(['settings', 'method'], form);
+  const vlessEncryption = useWatch({ control, name: 'settings.encryption' }) ?? '';
+  const ssMethod = useWatch({ control, name: 'settings.method' });
   const isSSWith2022 = isSS2022({
     protocol,
     settings: typeof ssMethod === 'string' ? { method: ssMethod } : {},
   });
-  const mixedUdpOn = Form.useWatch(['settings', 'udp'], form) ?? false;
-  const network = Form.useWatch(['streamSettings', 'network'], form) ?? '';
-  const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none';
+  const mixedUdpOn = (useWatch({ control, name: 'settings.udp' }) ?? false) as boolean;
+  const network = (useWatch({ control, name: 'streamSettings.network' }) ?? '') as string;
+  const security = (useWatch({ control, name: 'streamSettings.security' }) ?? 'none') as string;
   const streamEnabled = canEnableStream({ protocol });
   const sniffingSupported = canEnableSniffing({ protocol });
-  // Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
-  // user-selectable transport — their stream tab is just sockopt, which is all
-  // Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
-  // own dedicated transport form. For all of these the RAW/mKCP/WS/... network
-  // picker and the per-network sub-forms are hidden.
+  /*
+   * Wireguard (always a UDP listener) and Tunnel (dokodemo-door) expose no
+   * user-selectable transport — their stream tab is just sockopt, which is all
+   * Tunnel's TProxy/redirect mode needs (sockopt.tproxy). Hysteria carries its
+   * own dedicated transport form. For all of these the RAW/mKCP/WS/... network
+   * picker and the per-network sub-forms are hidden.
+   */
   const hasSelectableTransport =
     protocol !== Protocols.HYSTERIA
     && protocol !== Protocols.WIREGUARD
     && protocol !== Protocols.TUNNEL;
 
-  const wPort = Form.useWatch('port', form);
-  const wListen = (Form.useWatch('listen', form) ?? '') as string;
+  const wPort = useWatch({ control, name: 'port' });
+  const wListen = (useWatch({ control, name: 'listen' }) ?? '') as string;
   const isUdsListen = wListen.startsWith('/') || wListen.startsWith('@');
-  const wNodeId = Form.useWatch('nodeId', form) ?? null;
-  const shareAddrStrategy = Form.useWatch('shareAddrStrategy', form) ?? 'node';
-  const wTag = Form.useWatch('tag', form) ?? '';
-  const wSsNetwork = Form.useWatch(['settings', 'network'], form);
-  const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form);
+  const wNodeId = useWatch({ control, name: 'nodeId' }) ?? null;
+  const shareAddrStrategy = useWatch({ control, name: 'shareAddrStrategy' }) ?? 'node';
+  const wTag = (useWatch({ control, name: 'tag' }) ?? '') as string;
+  const wSsNetwork = useWatch({ control, name: 'settings.network' });
+  const wTunnelNetwork = useWatch({ control, name: 'settings.allowedNetwork' });
+  const wTotal = (useWatch({ control, name: 'total' }) as number | undefined) ?? 0;
+  const wExpiry = (useWatch({ control, name: 'expiryTime' }) as number | undefined) ?? 0;
   const autoTagRef = useRef(true);
   const lastWrittenTagRef = useRef('');
   const currentTagInput = (): InboundTagInput => ({
@@ -257,27 +286,24 @@ export default function InboundFormModal({
     setCertFromPanel,
     clearCertFiles,
     onSecurityChange,
-  } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
+  } = useSecurityActions({ methods, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
 
 
   const toggleSockopt = (on: boolean) => {
     if (on) {
-      form.setFieldValue(
-        ['streamSettings', 'sockopt'],
-        SockoptStreamSettingsSchema.parse({}),
-      );
+      setV('streamSettings.sockopt', SockoptStreamSettingsSchema.parse({}));
     } else {
-      form.setFieldValue(['streamSettings', 'sockopt'], undefined);
+      setV('streamSettings.sockopt', undefined);
     }
   };
-  const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form);
+  const wgSecretKey = useWatch({ control, name: 'settings.secretKey' });
   const wgPubKey = typeof wgSecretKey === 'string' && wgSecretKey.length > 0
     ? Wireguard.generateKeypair(wgSecretKey).publicKey
     : '';
 
   const regenInboundWg = () => {
     const kp = Wireguard.generateKeypair();
-    form.setFieldValue(['settings', 'secretKey'], kp.privateKey);
+    setV('settings.secretKey', kp.privateKey);
   };
 
   const matchesVlessAuth = (
@@ -306,16 +332,16 @@ export default function InboundFormModal({
       };
       const block = (obj.auths || []).find((a) => matchesVlessAuth(a, authId));
       if (!block) return;
-      form.setFieldValue(['settings', 'decryption'], block.decryption);
-      form.setFieldValue(['settings', 'encryption'], block.encryption);
+      setV('settings.decryption', block.decryption);
+      setV('settings.encryption', block.encryption);
     } finally {
       setSaving(false);
     }
   };
 
   const clearVlessEnc = () => {
-    form.setFieldValue(['settings', 'decryption'], 'none');
-    form.setFieldValue(['settings', 'encryption'], 'none');
+    setV('settings.decryption', 'none');
+    setV('settings.encryption', 'none');
   };
 
   const vlessAuthKind = vlessEncryptionAuthKind(
@@ -333,8 +359,7 @@ export default function InboundFormModal({
     const initial = mode === 'edit' && dbInbound
       ? rawInboundToFormValues(dbInbound)
       : buildAddModeValues();
-    form.resetFields();
-    form.setFieldsValue(initial);
+    methods.reset(initial);
     setScanResult(null);
     const initialTag = (initial.tag ?? '') as string;
     autoTagRef.current = isAutoInboundTag(initialTag, {
@@ -355,77 +380,67 @@ export default function InboundFormModal({
       loadFallbacks(null);
     }
 
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [open, mode, dbInbound, form]);
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
+  }, [open, mode, dbInbound, methods]);
 
   useEffect(() => {
     if (!open) return;
     if (wTag === lastWrittenTagRef.current) return;
     autoTagRef.current = isAutoInboundTag(wTag, currentTagInput());
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [open, wTag]);
 
   useEffect(() => {
     if (!open || !autoTagRef.current) return;
     const next = composeInboundTag(currentTagInput());
-    if (next !== (form.getFieldValue('tag') ?? '')) {
+    if (next !== ((getV('tag') as string | undefined) ?? '')) {
       lastWrittenTagRef.current = next;
-      form.setFieldValue('tag', next);
+      setV('tag', next);
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [open, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]);
 
-  // Keep the strategy value inside the visible option set: when `node` isn't
-  // offered (no node, or a protocol that can't deploy to one) fall back to
-  // `listen`, which yields the same link for a local inbound. Mirrors how the
-  // protocol reset drops a nodeId that no longer applies.
-  // Only downgrade once the inputs this decision depends on are settled, so a
-  // persisted `node` strategy is never clobbered by transient mount state (#5375):
-  //  - `availableNodesFetched`: an empty `availableNodes` during the async
-  //    /nodes/list fetch is a placeholder, not "no nodes".
-  //  - `protocol`: `Form.useWatch('protocol')` is briefly empty on the first
-  //    edit render before initialValues apply, which would momentarily make the
-  //    node option look unavailable.
+  /*
+   * Keep the strategy value inside the visible option set: when `node` isn't
+   * offered (no node, or a protocol that can't deploy to one) fall back to
+   * `listen`, which yields the same link for a local inbound. Mirrors how the
+   * protocol reset drops a nodeId that no longer applies.
+   * Only downgrade once the inputs this decision depends on are settled, so a
+   * persisted `node` strategy is never clobbered by transient mount state (#5375).
+   */
   useEffect(() => {
     if (!open) return;
     if (!availableNodesFetched || !protocol) return;
-    const current = form.getFieldValue('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
+    const current = getV('shareAddrStrategy') as InboundFormValues['shareAddrStrategy'] | undefined;
     if (!nodeShareOptionAvailable && (current ?? 'node') === 'node') {
-      form.setFieldValue('shareAddrStrategy', 'listen');
+      setV('shareAddrStrategy', 'listen');
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [open, availableNodesFetched, protocol, nodeShareOptionAvailable, shareAddrStrategy]);
 
-  // Why: protocol picker reset cascades through the form — clearing the
-  // settings DU branch and dropping a nodeId that no longer applies. The
-  // legacy modal did this imperatively in onProtocolChange; here we hook
-  // into AntD's onValuesChange and let setFieldValue keep the rest of
-  // the form state intact.
-  const onValuesChange = (changed: Partial<InboundFormValues>) => {
+  /*
+   * Protocol picker reset cascades through the form — clearing the settings DU
+   * branch and dropping a nodeId that no longer applies. Only a real user
+   * change (type === 'change') triggers it; programmatic setValue (advanced
+   * JSON edits, open reset) must not, matching the legacy onValuesChange.
+   */
+  useEffect(() => {
     if (mode === 'edit') return;
-    if ('protocol' in changed && typeof changed.protocol === 'string') {
-      const next = changed.protocol;
+    /* eslint-disable-next-line react-hooks/incompatible-library */
+    const sub = methods.watch((_value, { name, type }) => {
+      if (name !== 'protocol' || type !== 'change') return;
+      const next = getV('protocol') as string;
       const settings = createDefaultInboundSettings(next) ?? undefined;
-      form.setFieldValue('settings', settings);
+      setV('settings', settings);
       if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
-        form.setFieldValue('nodeId', null);
+        setV('nodeId', null);
       }
-      // Hysteria uses its dedicated transport — force the network branch
-      // so the stream tab renders the hysteria sub-form, not the leftover
-      // tcpSettings from the previous protocol. When leaving hysteria,
-      // snap back to TCP so the standard network selector has a valid
-      // starting point.
       if (next === Protocols.HYSTERIA) {
-        form.setFieldValue('streamSettings', {
+        setV('streamSettings', {
           network: 'hysteria',
           security: 'tls',
           hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
           tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
-          // Hysteria2 needs an obfs wrapper on the FinalMask side; seed
-          // it with salamander + a random password so the listener boots
-          // with a usable default. Re-selecting Hysteria from another
-          // protocol re-runs this and refreshes the password — that's
-          // intentional, the form was already being reset.
           finalmask: {
             tcp: [],
             udp: [{
@@ -435,37 +450,28 @@ export default function InboundFormModal({
           },
         });
       } else if (next === Protocols.WIREGUARD || next === Protocols.TUNNEL) {
-        // Wireguard and Tunnel (dokodemo-door) have no user-selectable
-        // transport: wireguard is always a UDP listener, and tunnel only needs
-        // `sockopt.tproxy` for its TProxy/redirect mode. Drop the leftover
-        // network/transport slices so the stream tab doesn't render a TCP
-        // sub-form and the wire payload carries no dead tcpSettings — the
-        // sockopt section (with TProxy) stays available.
-        form.setFieldValue('streamSettings', { security: 'none' });
+        setV('streamSettings', { security: 'none' });
       } else {
-        const current = form.getFieldValue('streamSettings') as { network?: string } | undefined;
+        const current = getV('streamSettings') as { network?: string } | undefined;
         if (current?.network === 'hysteria' || !current?.network) {
-          form.setFieldValue('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
+          setV('streamSettings', { network: 'tcp', security: 'none', tcpSettings: {} });
         }
       }
-    }
-  };
+    });
+    return () => sub.unsubscribe();
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
+  }, [mode, methods]);
 
   const submit = async () => {
-    try {
-      await form.validateFields();
-    } catch {
-      return;
-    }
-    // Why getFieldsValue(true) instead of the validateFields return value:
-    // rc-component/form's validateFields filters its output by REGISTERED
-    // name paths. settings.clients and settings.fallbacks have no Form.Item
-    // bound to them (clients are managed via the standalone Client modal,
-    // not inside this inbound modal) — so validateFields would drop them
-    // and the update wire payload would silently delete every client on
-    // every save. getFieldsValue(true) returns the entire form store and
-    // keeps those sub-trees intact.
-    const values = form.getFieldsValue(true) as InboundFormValues;
+    if (!(await methods.trigger())) return;
+    /*
+     * getValues() returns the entire form store, including settings.clients and
+     * settings.fallbacks which have no bound field (clients are managed via the
+     * standalone Client modal, not this inbound modal). With shouldUnregister
+     * false those pass-through sub-trees survive from the reset object, so the
+     * update wire payload never silently drops every client on save.
+     */
+    const values = methods.getValues() as InboundFormValues;
     const parsed = InboundFormSchema.safeParse(values);
     if (!parsed.success) {
       const issues = parsed.error.issues;
@@ -509,24 +515,16 @@ export default function InboundFormModal({
 
   const basicTab = (
     <>
-      <Form.Item name="tag" hidden noStyle><Input /></Form.Item>
-      <Form.Item name="up" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="down" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="total" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="expiryTime" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="lastTrafficResetTime" hidden noStyle><InputNumber /></Form.Item>
-      <Form.Item name="clientStats" hidden noStyle><Input /></Form.Item>
-
-      <Form.Item name="enable" label={t('enable')} valuePropName="checked">
+      <FormField name="enable" label={t('enable')} valueProp="checked">
         <Switch />
-      </Form.Item>
+      </FormField>
 
-      <Form.Item name="remark" label={t('pages.inbounds.remark')}>
+      <FormField name="remark" label={t('pages.inbounds.remark')}>
         <Input />
-      </Form.Item>
+      </FormField>
 
       {selectableNodes.length > 0 && isNodeEligible && (
-        <Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
+        <FormField name="nodeId" label={t('pages.inbounds.deployTo')}>
           <Select
             showSearch
             disabled={mode === 'edit'}
@@ -538,21 +536,21 @@ export default function InboundFormModal({
               disabled: n.status === 'offline',
             }))}
           />
-        </Form.Item>
+        </FormField>
       )}
 
-      <Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
-        <Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
-      </Form.Item>
+      <FormField name="protocol" label={t('pages.inbounds.protocol')}>
+        <Select id="protocol" disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
+      </FormField>
 
-      <Form.Item
+      <FormField
         name="listen"
         label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
       >
         <Input placeholder={t('pages.inbounds.monitorDesc')} />
-      </Form.Item>
+      </FormField>
 
-      <Form.Item
+      <FormField
         name="shareAddrStrategy"
         label={labelWithHint(t('pages.inbounds.form.shareAddrStrategy'), t('pages.inbounds.form.shareAddrStrategyHelp'))}
       >
@@ -564,38 +562,35 @@ export default function InboundFormModal({
               label: t(`pages.inbounds.form.shareAddrStrategyOptions.${strategy}`),
             }))}
         />
-      </Form.Item>
+      </FormField>
 
       {shareAddrStrategy === 'custom' && (
-        <Form.Item
+        <FormField
           name="shareAddr"
           label={labelWithHint(t('pages.inbounds.form.shareAddr'), t('pages.inbounds.form.shareAddrHelp'))}
-          rules={[{
-            validator: (_, value) => (
-              isValidShareAddrInput(String(value ?? ''))
-                ? Promise.resolve()
-                : Promise.reject(new Error(t('pages.inbounds.form.shareAddrHelp')))
-            ),
-          }]}
+          rules={{
+            validate: (value) =>
+              isValidShareAddrInput(String(value ?? '')) || t('pages.inbounds.form.shareAddrHelp'),
+          }}
         >
           <Input placeholder="edge.example.com" />
-        </Form.Item>
+        </FormField>
       )}
 
-      <Form.Item
+      <FormField
         name="subSortIndex"
         label={labelWithHint(t('pages.inbounds.form.subSortIndex'), t('pages.inbounds.form.subSortIndexHelp'))}
       >
         <InputNumber min={1} />
-      </Form.Item>
+      </FormField>
 
-      <Form.Item
+      <FormField
         name="port"
         label={t('pages.inbounds.port')}
-        rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
+        rules={{ validate: rhfZodValidate(InboundFormBaseSchema.shape.port) }}
       >
         <InputNumber min={isUdsListen ? 0 : 1} max={65535} />
-      </Form.Item>
+      </FormField>
 
       <Form.Item
         label={
@@ -604,41 +599,25 @@ export default function InboundFormModal({
           </Tooltip>
         }
       >
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) => prev.total !== curr.total}
-        >
-          {({ getFieldValue, setFieldValue }) => {
-            const totalBytes = (getFieldValue('total') as number) ?? 0;
-            const totalGB = totalBytes
-              ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100
-              : 0;
-            return (
-              <InputNumber
-                value={totalGB}
-                min={0}
-                step={1}
-                onChange={(v) => {
-                  const bytes = NumberFormatter.toFixed(
-                    (Number(v) || 0) * SizeFormatter.ONE_GB,
-                    0,
-                  );
-                  setFieldValue('total', bytes);
-                }}
-              />
-            );
+        <InputNumber
+          value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
+          min={0}
+          step={1}
+          onChange={(v) => {
+            const bytes = NumberFormatter.toFixed((Number(v) || 0) * SizeFormatter.ONE_GB, 0);
+            setV('total', bytes);
           }}
-        </Form.Item>
+        />
       </Form.Item>
 
-      <Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
+      <FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
         <Select
           options={TRAFFIC_RESETS.map((r) => ({
             value: r,
             label: t(`pages.inbounds.periodicTrafficReset.${r}`),
           }))}
         />
-      </Form.Item>
+      </FormField>
 
       <Form.Item
         label={
@@ -647,20 +626,10 @@ export default function InboundFormModal({
           </Tooltip>
         }
       >
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
-        >
-          {({ getFieldValue, setFieldValue }) => {
-            const expiry = (getFieldValue('expiryTime') as number) ?? 0;
-            return (
-              <DateTimePicker
-                value={expiry > 0 ? dayjs(expiry) : null}
-                onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
-              />
-            );
-          }}
-        </Form.Item>
+        <DateTimePicker
+          value={wExpiry > 0 ? dayjs(wExpiry) : null}
+          onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
+        />
       </Form.Item>
     </>
   );
@@ -690,7 +659,7 @@ export default function InboundFormModal({
 
       {protocol === Protocols.MTPROTO && <MtprotoFields />}
 
-      {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
+      {protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields isSSWith2022={isSSWith2022} />}
 
       {protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} vlessAuthKind={vlessAuthKind} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
 
@@ -707,41 +676,19 @@ export default function InboundFormModal({
     </>
   );
 
-  // Switching `network` swaps which per-network key (tcpSettings,
-  // wsSettings, grpcSettings, ...) appears on the wire. Clear the old
-  // network's blob and seed the new one with the schema defaults so the
-  // Form.Items inside it have valid initial values (KCP needs MTU=1350
-  // etc., not empty strings).
-  // Seed each network's settings blob with its Zod schema defaults so
-  // every Form.Item inside the network sub-form has a defined starting
-  // value. XHTTP in particular has ~20 fields (sessionIDPlacement,
-  // seqPlacement, xPaddingMethod, uplinkHTTPMethod, ...) whose value
-  // is the literal "" sentinel meaning "let xray-core pick its
-  // default". Without seeding "", the Form.Item reads `undefined` and
-  // the Select shows blank instead of the "Default (path)" option.
-  const newStreamSlice = (n: string): Record<string, unknown> => {
-    switch (n) {
-      case 'tcp': return TcpStreamSettingsSchema.parse({ header: { type: 'none' } });
-      case 'kcp': return KcpStreamSettingsSchema.parse({});
-      case 'ws': return WsStreamSettingsSchema.parse({});
-      case 'grpc': return GrpcStreamSettingsSchema.parse({});
-      case 'httpupgrade': return HttpUpgradeStreamSettingsSchema.parse({});
-      case 'xhttp': return XHttpStreamSettingsSchema.parse({});
-      default: return {};
-    }
-  };
+  /*
+   * Switching `network` swaps which per-network key appears on the wire. Clear
+   * the old network's blob and seed the new one with schema defaults, plus the
+   * FinalMask mkcp-legacy UDP mask when moving to mKCP (removed otherwise).
+   */
   const onNetworkChange = (next: string) => {
     const ALL = ['tcpSettings', 'kcpSettings', 'wsSettings', 'grpcSettings', 'httpupgradeSettings', 'xhttpSettings'];
-    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
+    const current = (getV('streamSettings') as Record<string, unknown>) ?? {};
     const cleaned: Record<string, unknown> = { ...current, network: next };
     for (const k of ALL) {
       if (k !== `${next}Settings`) delete cleaned[k];
     }
     cleaned[`${next}Settings`] = newStreamSlice(next);
-    // mKCP wants a UDP mask wrapper on the FinalMask side; seed it with
-    // `mkcp-legacy` so the inbound boots with a sensible default
-    // instead of unobfuscated mKCP traffic. The user can still edit or
-    // clear the mask via the FinalMask section.
     if (next === 'kcp') {
       const fm = (cleaned.finalmask as Record<string, unknown> | undefined) ?? {};
       const udp = Array.isArray(fm.udp) ? (fm.udp as unknown[]) : [];
@@ -762,15 +709,16 @@ export default function InboundFormModal({
         cleaned.finalmask = { ...fm, udp };
       }
     }
-    form.setFieldValue('streamSettings', cleaned);
+    setV('streamSettings', cleaned);
   };
 
   const streamTab = (
     <>
       {hasSelectableTransport && (
-        <Form.Item label={t('transmission')} name={['streamSettings', 'network']}>
+        <Form.Item label={t('transmission')}>
           <Select
             style={{ width: '75%' }}
+            value={network}
             onChange={onNetworkChange}
             options={[
               { value: 'tcp', label: 'RAW' },
@@ -786,12 +734,8 @@ export default function InboundFormModal({
 
       {/* Inbound Hysteria stream sub-form. The transport for hysteria
           isn't user-selectable (always 'hysteria'), so the network
-          dropdown is hidden above. Fields here mirror the legacy
-          HysteriaStreamSettings inbound class: version is locked to 2,
-          auth + udpIdleTimeout are required, masquerade is an optional
-          sub-object that lets xray-core disguise the listener as an
-          HTTP server when probed. */}
-      {protocol === Protocols.HYSTERIA && <HysteriaFields form={form} />}
+          dropdown is hidden above. */}
+      {protocol === Protocols.HYSTERIA && <HysteriaFields />}
 
       {hasSelectableTransport && (
         <>
@@ -801,7 +745,7 @@ export default function InboundFormModal({
 
           {network === 'grpc' && <GrpcForm />}
 
-          {network === 'xhttp' && <XhttpForm form={form} />}
+          {network === 'xhttp' && <XhttpForm />}
 
           {network === 'httpupgrade' && <HttpUpgradeForm />}
 
@@ -813,56 +757,45 @@ export default function InboundFormModal({
           field is still parsed/rendered for backward compatibility but is no
           longer editable here. */}
 
-      <SockoptForm toggleSockopt={toggleSockopt} network={network as string} />
+      <SockoptForm toggleSockopt={toggleSockopt} network={network} />
 
       {/* Transport masks don't apply to tunnel (a transparent forwarder), so
           its stream tab is just sockopt + TProxy. */}
       {protocol !== Protocols.TUNNEL && (
-        <FinalMaskForm
-          name={['streamSettings', 'finalmask']}
-          network={network as string}
-          protocol={protocol}
-          form={form}
+        <Controller
+          control={control}
+          name="streamSettings.finalmask"
+          render={({ field }) => (
+            <FinalMaskField
+              key={`${protocol}:${network}`}
+              value={field.value}
+              onChange={field.onChange}
+              network={network}
+              protocol={protocol}
+            />
+          )}
         />
       )}
     </>
   );
 
+  const tlsOk = canEnableTls({ protocol, streamSettings: { network, security } });
+  const realityOk = canEnableReality({ protocol, streamSettings: { network, security } });
+  const tlsOnly = protocol === Protocols.HYSTERIA;
+
   const securityTab = (
     <>
-      <Form.Item name={['streamSettings', 'security']} hidden noStyle>
-        <Input />
-      </Form.Item>
       <Form.Item label={t('pages.inbounds.securityTab')}>
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) =>
-            prev.streamSettings?.security !== curr.streamSettings?.security
-            || prev.streamSettings?.network !== curr.streamSettings?.network
-            || prev.protocol !== curr.protocol
-          }
+        <Radio.Group
+          value={security}
+          buttonStyle="solid"
+          disabled={!tlsOk}
+          onChange={(e) => onSecurityChange(e.target.value)}
         >
-          {({ getFieldValue }) => {
-            const sec = getFieldValue(['streamSettings', 'security']) ?? 'none';
-            const net = getFieldValue(['streamSettings', 'network']) ?? '';
-            const proto = getFieldValue('protocol') ?? '';
-            const tlsOk = canEnableTls({ protocol: proto, streamSettings: { network: net, security: sec } });
-            const realityOk = canEnableReality({ protocol: proto, streamSettings: { network: net, security: sec } });
-            const tlsOnly = proto === Protocols.HYSTERIA;
-            return (
-              <Radio.Group
-                value={sec}
-                buttonStyle="solid"
-                disabled={!tlsOk}
-                onChange={(e) => onSecurityChange(e.target.value)}
-              >
-                {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
-                <Radio.Button value="tls">TLS</Radio.Button>
-                {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
-              </Radio.Group>
-            );
-          }}
-        </Form.Item>
+          {!tlsOnly && <Radio.Button value="none">{t('none')}</Radio.Button>}
+          <Radio.Button value="tls">TLS</Radio.Button>
+          {realityOk && <Radio.Button value="reality">Reality</Radio.Button>}
+        </Radio.Group>
       </Form.Item>
 
       {security === 'tls' && (
@@ -916,7 +849,7 @@ export default function InboundFormModal({
                   <div className="advanced-editor-meta">
                     {t('pages.inbounds.advanced.allHelp')}
                   </div>
-                  <AdvancedAllEditor form={form} streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
+                  <AdvancedAllEditor streamEnabled={streamEnabled} sniffingEnabled={sniffingSupported} />
                 </>
               ),
             },
@@ -930,7 +863,6 @@ export default function InboundFormModal({
                     <code>{'{ settings: { ... } }'}</code>.
                   </div>
                   <AdvancedSliceEditor
-                    form={form}
                     path="settings"
                     wrapKey="settings"
                     minHeight="320px"
@@ -950,7 +882,6 @@ export default function InboundFormModal({
                       <code>{'{ streamSettings: { ... } }'}</code>.
                     </div>
                     <AdvancedSliceEditor
-                      form={form}
                       path="streamSettings"
                       wrapKey="streamSettings"
                       minHeight="320px"
@@ -971,7 +902,6 @@ export default function InboundFormModal({
                       <code>{'{ sniffing: { ... } }'}</code>.
                     </div>
                     <AdvancedSliceEditor
-                      form={form}
                       path="sniffing"
                       wrapKey="sniffing"
                       minHeight="240px"
@@ -1004,50 +934,42 @@ export default function InboundFormModal({
         onCancel={onClose}
         destroyOnHidden
       >
-        <Form
-          form={form}
-          colon={false}
-          labelCol={{ sm: { span: 8 } }}
-          wrapperCol={{ sm: { span: 14 } }}
-          labelWrap
-          onValuesChange={onValuesChange}
-        >
-          <Tabs items={[
-            // forceRender on every tab so all Form.Items register at modal
-            // open, not lazily on first visit. Without it, AntD's items API
-            // lazy-mounts inactive tabs — their fields don't register, so
-            // Form.useWatch on a parent path (e.g. 'sniffing') returns the
-            // partial-view {} until the user touches the tab and the
-            // inner Form.Item for `sniffing.enabled` registers.
-            { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
-            ...(([
-              Protocols.VLESS,
-              Protocols.SHADOWSOCKS,
-              Protocols.HTTP,
-              Protocols.MIXED,
-              Protocols.TUNNEL,
-              Protocols.TUN,
-              Protocols.WIREGUARD,
-              Protocols.MTPROTO,
-            ] as string[]).includes(protocol) || isFallbackHost
-              ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
-              : []),
-            ...(streamEnabled
-              ? [
-                { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
-                // Wireguard and Tunnel can't do TLS/Reality (canEnableTls is false), so
-                // the security tab would only show a fully disabled radio.
-                ...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
-                  ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
-                  : []),
-              ]
-              : []),
-            ...(sniffingSupported
-              ? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
-              : []),
-            { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
-          ]} />
-        </Form>
+        <FormProvider {...methods}>
+          <Form
+            colon={false}
+            labelCol={{ sm: { span: 8 } }}
+            wrapperCol={{ sm: { span: 14 } }}
+            labelWrap
+          >
+            <Tabs items={[
+              { key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab, forceRender: true },
+              ...(([
+                Protocols.VLESS,
+                Protocols.SHADOWSOCKS,
+                Protocols.HTTP,
+                Protocols.MIXED,
+                Protocols.TUNNEL,
+                Protocols.TUN,
+                Protocols.WIREGUARD,
+                Protocols.MTPROTO,
+              ] as string[]).includes(protocol) || isFallbackHost
+                ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
+                : []),
+              ...(streamEnabled
+                ? [
+                  { key: 'stream', label: t('pages.inbounds.streamTab'), children: streamTab, forceRender: true },
+                  ...(protocol !== Protocols.WIREGUARD && protocol !== Protocols.TUNNEL
+                    ? [{ key: 'security', label: t('pages.inbounds.securityTab'), children: securityTab, forceRender: true }]
+                    : []),
+                ]
+                : []),
+              ...(sniffingSupported
+                ? [{ key: 'sniffing', label: t('pages.inbounds.sniffingTab'), children: sniffingTab, forceRender: true }]
+                : []),
+              { key: 'advanced', label: t('pages.xray.advancedTemplate'), children: advancedTab, forceRender: true },
+            ]} />
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 13 - 7
frontend/src/pages/inbounds/form/SniffingTab.tsx

@@ -1,16 +1,22 @@
 import { useTranslation } from 'react-i18next';
-import { Form } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
 
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { SniffingField } from '@/lib/xray/forms/fields';
 
 export default function SniffingTab() {
   const { t } = useTranslation();
-  const form = Form.useFormInstance();
+  const { control } = useFormContext();
   return (
-    <SniffingFields
-      name={['sniffing']}
-      form={form}
-      enableLabel={t('enable')}
+    <Controller
+      control={control}
+      name="sniffing"
+      render={({ field }) => (
+        <SniffingField
+          value={field.value}
+          onChange={field.onChange}
+          enableLabel={t('enable')}
+        />
+      )}
     />
   );
 }

+ 54 - 58
frontend/src/pages/inbounds/form/advanced-editors.tsx

@@ -1,6 +1,5 @@
 import { useEffect, useRef, useState } from 'react';
-import { Form, type FormInstance } from 'antd';
-import type { NamePath } from 'antd/es/form/interface';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { JsonEditor } from '@/components/form';
 import {
@@ -9,46 +8,44 @@ import {
   normalizeClients,
   dropLegacyOptionalEmpties,
 } from '@/lib/xray/inbound-form-adapter';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
 
-// Sub-editor for one slice of the form (settings, streamSettings, sniffing).
-// Holds a local text buffer so the user can type freely; on every keystroke
-// we try to JSON.parse and forward the result to form state. Invalid JSON
-// is held in the buffer until the next valid moment — no panic on partial
-// input. The buffer seeds once on mount; the modal's destroyOnHidden makes
-// each open a fresh editor instance, so we don't need to re-sync on outer
-// form changes.
+/*
+ * Sub-editor for one slice of the form (settings, streamSettings, sniffing).
+ * Holds a local text buffer so the user can type freely; on every keystroke
+ * we try to JSON.parse and forward the result to form state. Invalid JSON
+ * is held in the buffer until the next valid moment — no panic on partial
+ * input. The buffer seeds once on mount; the modal's destroyOnHidden makes
+ * each open a fresh editor instance, so we don't need to re-sync on outer
+ * form changes.
+ */
 export function AdvancedSliceEditor({
-  form,
   path,
   wrapKey,
   minHeight,
   maxHeight,
 }: {
-  form: FormInstance<InboundFormValues>;
-  path: NamePath;
-  // When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
-  // the JSON the user sees matches the wire shape's slice envelope (e.g.
-  // `{ "settings": { ... } }`). Edits unwrap the outer key before writing
-  // back to the form. Mirrors the legacy modal's wrappedConfigValue.
+  path: string;
+  /*
+   * When set, the editor wraps the inner value with `{ [wrapKey]: ... }` so
+   * the JSON the user sees matches the wire shape's slice envelope (e.g.
+   * `{ "settings": { ... } }`). Edits unwrap the outer key before writing
+   * back to the form. Mirrors the legacy modal's wrappedConfigValue.
+   */
   wrapKey?: string;
   minHeight?: string;
   maxHeight?: string;
 }) {
+  const { control, getValues, setValue } = useFormContext();
+
   const serialize = (value: unknown): string => {
     const inner = value ?? {};
     return JSON.stringify(wrapKey ? { [wrapKey]: inner } : inner, null, 2);
   };
 
-  // preserve: true so useWatch returns the full subtree from the form
-  // store — without it, useWatch goes through getFieldsValue() which
-  // filters out unregistered fields. Slices like `settings` would lose
-  // their `clients` / `fallbacks` sub-trees because those aren't bound
-  // to any Form.Item.
-  const watched = Form.useWatch(path, { form, preserve: true });
+  const watched = useWatch({ control, name: path });
   const lastEmitRef = useRef<string>('');
   const [text, setText] = useState(() => {
-    const initial = serialize(form.getFieldValue(path));
+    const initial = serialize(getValues(path));
     lastEmitRef.current = initial;
     return initial;
   });
@@ -58,7 +55,7 @@ export function AdvancedSliceEditor({
     if (formStr === lastEmitRef.current) return;
     setText(formStr);
     lastEmitRef.current = formStr;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [watched, wrapKey]);
 
   return (
@@ -73,48 +70,47 @@ export function AdvancedSliceEditor({
           const toWrite = wrapKey && parsed && typeof parsed === 'object' && !Array.isArray(parsed)
             ? (parsed as Record<string, unknown>)[wrapKey] ?? {}
             : parsed;
-          form.setFieldValue(path, toWrite);
+          setValue(path, toWrite);
           lastEmitRef.current = JSON.stringify(wrapKey ? { [wrapKey]: toWrite } : toWrite, null, 2);
         } catch {
-          // invalid JSON; keep buffer, don't push to form
+          /* invalid JSON; keep buffer, don't push to form */
         }
       }}
     />
   );
 }
 
-// The "All" editor shows the full inbound JSON in one editor: top-level
-// connection fields plus the three nested sub-objects (settings,
-// streamSettings, sniffing). Edits round-trip back to the form's slices,
-// mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
-// works the same way as AdvancedSliceEditor: useWatch on the slices we
-// care about, lastEmitRef as the "we wrote this" guard.
+/*
+ * The "All" editor shows the full inbound JSON in one editor: top-level
+ * connection fields plus the three nested sub-objects (settings,
+ * streamSettings, sniffing). Edits round-trip back to the form's slices,
+ * mirroring the legacy modal's setAdvancedAllValue behavior. Reactivity
+ * works the same way as AdvancedSliceEditor: useWatch on the slices we
+ * care about, lastEmitRef as the "we wrote this" guard.
+ */
 export function AdvancedAllEditor({
-  form,
   streamEnabled,
   sniffingEnabled,
 }: {
-  form: FormInstance<InboundFormValues>;
   streamEnabled: boolean;
   sniffingEnabled: boolean;
 }) {
-  // preserve: true — default useWatch returns only registered fields, so
-  // sub-trees we never bound (settings.clients/fallbacks, sniffing
-  // defaults, etc.) wouldn't show up. preserve switches the read to
-  // getFieldsValue(true) which returns the full form store.
-  const wListen = Form.useWatch('listen', { form, preserve: true });
-  const wPort = Form.useWatch('port', { form, preserve: true });
-  const wProtocol = Form.useWatch('protocol', { form, preserve: true });
-  const wTag = Form.useWatch('tag', { form, preserve: true });
-  const wSettings = Form.useWatch('settings', { form, preserve: true });
-  const wSniffing = Form.useWatch('sniffing', { form, preserve: true });
-  const wStream = Form.useWatch('streamSettings', { form, preserve: true });
+  const { control, setValue } = useFormContext();
+  const wListen = useWatch({ control, name: 'listen' });
+  const wPort = useWatch({ control, name: 'port' });
+  const wProtocol = useWatch({ control, name: 'protocol' });
+  const wTag = useWatch({ control, name: 'tag' });
+  const wSettings = useWatch({ control, name: 'settings' });
+  const wSniffing = useWatch({ control, name: 'sniffing' });
+  const wStream = useWatch({ control, name: 'streamSettings' });
 
   const serialize = () => {
-    // Apply the same prune/normalize as the wire payload so the JSON
-    // shown here is what the panel actually POSTs (no empty defaults,
-    // disabled sniffing as { enabled: false }, finalmask dropped when
-    // there are no masks).
+    /*
+     * Apply the same prune/normalize as the wire payload so the JSON
+     * shown here is what the panel actually POSTs (no empty defaults,
+     * disabled sniffing as { enabled: false }, finalmask dropped when
+     * there are no masks).
+     */
     const settingsView = (pruneEmpty(wSettings ?? {}) ?? {}) as Record<string, unknown>;
     if (typeof wProtocol === 'string' && Array.isArray(settingsView.clients)) {
       settingsView.clients = normalizeClients(wProtocol, settingsView.clients);
@@ -149,7 +145,7 @@ export function AdvancedAllEditor({
     if (formStr === lastEmitRef.current) return;
     setText(formStr);
     lastEmitRef.current = formStr;
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+    /* eslint-disable-next-line react-hooks/exhaustive-deps */
   }, [wListen, wPort, wProtocol, wTag, wSettings, wSniffing, wStream, streamEnabled, sniffingEnabled]);
 
   return (
@@ -166,20 +162,20 @@ export function AdvancedAllEditor({
           return;
         }
         if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return;
-        if (typeof parsed.listen === 'string') form.setFieldValue('listen', parsed.listen);
+        if (typeof parsed.listen === 'string') setValue('listen', parsed.listen);
         if (typeof parsed.port === 'number' && Number.isFinite(parsed.port)) {
-          form.setFieldValue('port', parsed.port);
+          setValue('port', parsed.port);
         }
-        if (typeof parsed.protocol === 'string') form.setFieldValue('protocol', parsed.protocol);
-        if (typeof parsed.tag === 'string') form.setFieldValue('tag', parsed.tag);
+        if (typeof parsed.protocol === 'string') setValue('protocol', parsed.protocol);
+        if (typeof parsed.tag === 'string') setValue('tag', parsed.tag);
         if (parsed.settings && typeof parsed.settings === 'object') {
-          form.setFieldValue('settings', parsed.settings);
+          setValue('settings', parsed.settings);
         }
         if (sniffingEnabled && parsed.sniffing && typeof parsed.sniffing === 'object') {
-          form.setFieldValue('sniffing', parsed.sniffing);
+          setValue('sniffing', parsed.sniffing);
         }
         if (streamEnabled && parsed.streamSettings && typeof parsed.streamSettings === 'object') {
-          form.setFieldValue('streamSettings', parsed.streamSettings);
+          setValue('streamSettings', parsed.streamSettings);
         }
         lastEmitRef.current = next;
       }}

+ 34 - 34
frontend/src/pages/inbounds/form/protocols/accounts-list.tsx

@@ -1,47 +1,47 @@
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, Space } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
 
 import { RandomUtil } from '@/utils';
 import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
 
 export default function AccountsList() {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({ control, name: 'settings.accounts' });
   return (
-    <Form.List name={['settings', 'accounts']}>
-      {(fields, { add, remove }) => (
-        <>
-          <Form.Item label={t('pages.inbounds.form.accounts')}>
-            <Button
-              size="small"
-              onClick={() => add({
-                user: RandomUtil.randomLowerAndNum(8),
-                pass: RandomUtil.randomLowerAndNum(12),
-              })}
-            >
-              <PlusOutlined /> {t('add')}
-            </Button>
-          </Form.Item>
-          {fields.length > 0 && (
-            <Form.Item wrapperCol={{ span: 24 }}>
-              {fields.map((field, idx) => (
-                <Space.Compact key={field.key} className="mb-8" block>
-                  <InputAddon>{String(idx + 1)}</InputAddon>
-                  <Form.Item name={[field.name, 'user']} noStyle>
-                    <Input placeholder={t('username')} />
-                  </Form.Item>
-                  <Form.Item name={[field.name, 'pass']} noStyle>
-                    <Input placeholder={t('password')} />
-                  </Form.Item>
-                  <Button aria-label={t('remove')} onClick={() => remove(field.name)}>
-                    <MinusOutlined />
-                  </Button>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-          )}
-        </>
+    <>
+      <Form.Item label={t('pages.inbounds.form.accounts')}>
+        <Button
+          size="small"
+          onClick={() => append({
+            user: RandomUtil.randomLowerAndNum(8),
+            pass: RandomUtil.randomLowerAndNum(12),
+          })}
+        >
+          <PlusOutlined /> {t('add')}
+        </Button>
+      </Form.Item>
+      {fields.length > 0 && (
+        <Form.Item wrapperCol={{ span: 24 }}>
+          {fields.map((field, idx) => (
+            <Space.Compact key={field.id} className="mb-8" block>
+              <InputAddon>{String(idx + 1)}</InputAddon>
+              <FormField name={['settings', 'accounts', idx, 'user']} noStyle>
+                <Input placeholder={t('username')} />
+              </FormField>
+              <FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
+                <Input placeholder={t('password')} />
+              </FormField>
+              <Button aria-label={t('remove')} onClick={() => remove(idx)}>
+                <MinusOutlined />
+              </Button>
+            </Space.Compact>
+          ))}
+        </Form.Item>
       )}
-    </Form.List>
+    </>
   );
 }

+ 5 - 4
frontend/src/pages/inbounds/form/protocols/http.tsx

@@ -1,6 +1,7 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Switch } from 'antd';
+import { Switch } from 'antd';
 
+import { FormField } from '@/components/form/rhf';
 import AccountsList from './accounts-list';
 
 export default function HttpFields() {
@@ -8,13 +9,13 @@ export default function HttpFields() {
   return (
     <>
       <AccountsList />
-      <Form.Item
+      <FormField
         name={['settings', 'allowTransparent']}
         label={t('pages.inbounds.form.allowTransparent')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 97 - 101
frontend/src/pages/inbounds/form/protocols/hysteria.tsx

@@ -1,128 +1,124 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
 
-export default function HysteriaFields({ form }: { form: FormInstance }) {
+export default function HysteriaFields() {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
+    | { type?: string }
+    | undefined;
+  const masqType = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade.type' }) as
+    | string
+    | undefined;
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.form.version')}
         name={['streamSettings', 'hysteriaSettings', 'version']}
       >
         <InputNumber min={2} max={2} disabled />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.udpIdleTimeout')}
         name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
       >
         <InputNumber min={2} max={600} style={{ width: '100%' }} />
-      </Form.Item>
+      </FormField>
 
       <Form.Item label={t('pages.inbounds.form.masquerade')}>
-        <Form.Item shouldUpdate noStyle>
-          {() => {
-            const m = form.getFieldValue(MASQ_PATH);
-            return (
-              <Switch
-                checked={!!m}
-                onChange={(checked) =>
-                  form.setFieldValue(
-                    MASQ_PATH,
-                    checked
-                      ? {
-                        type: '', dir: '', url: '',
-                        rewriteHost: false, insecure: false,
-                        content: '', headers: {}, statusCode: 0,
-                      }
-                      : undefined,
-                  )
+        <Switch
+          checked={!!masq}
+          onChange={(checked) =>
+            setValue(
+              'streamSettings.hysteriaSettings.masquerade',
+              checked
+                ? {
+                  type: '', dir: '', url: '',
+                  rewriteHost: false, insecure: false,
+                  content: '', headers: {}, statusCode: 0,
                 }
-              />
-            );
-          }}
-        </Form.Item>
+                : undefined,
+            )
+          }
+        />
       </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
-          if (!m) return null;
-          return (
+      {masq && (
+        <>
+          <FormField
+            label={t('pages.inbounds.form.type')}
+            name={[...MASQ_PATH, 'type']}
+          >
+            <Select
+              options={[
+                { value: '', label: 'default (404 page)' },
+                { value: 'proxy', label: 'proxy (reverse proxy)' },
+                { value: 'file', label: 'file (serve directory)' },
+                { value: 'string', label: 'string (fixed body)' },
+              ]}
+            />
+          </FormField>
+          {masqType === 'proxy' && (
             <>
-              <Form.Item
-                label={t('pages.inbounds.form.type')}
-                name={[...MASQ_PATH, 'type']}
+              <FormField
+                label={t('pages.inbounds.form.upstreamUrl')}
+                name={[...MASQ_PATH, 'url']}
+              >
+                <Input placeholder="https://www.example.com" />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.rewriteHost')}
+                name={[...MASQ_PATH, 'rewriteHost']}
+                valueProp="checked"
               >
-                <Select
-                  options={[
-                    { value: '', label: 'default (404 page)' },
-                    { value: 'proxy', label: 'proxy (reverse proxy)' },
-                    { value: 'file', label: 'file (serve directory)' },
-                    { value: 'string', label: 'string (fixed body)' },
-                  ]}
-                />
-              </Form.Item>
-              {m.type === 'proxy' && (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.upstreamUrl')}
-                    name={[...MASQ_PATH, 'url']}
-                  >
-                    <Input placeholder="https://www.example.com" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.rewriteHost')}
-                    name={[...MASQ_PATH, 'rewriteHost']}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.skipTlsVerify')}
-                    name={[...MASQ_PATH, 'insecure']}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                </>
-              )}
-              {m.type === 'file' && (
-                <Form.Item
-                  label={t('pages.inbounds.form.directory')}
-                  name={[...MASQ_PATH, 'dir']}
-                >
-                  <Input placeholder="/var/www/html" />
-                </Form.Item>
-              )}
-              {m.type === 'string' && (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.statusCode')}
-                    name={[...MASQ_PATH, 'statusCode']}
-                  >
-                    <InputNumber min={0} max={599} style={{ width: '100%' }} />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.body')}
-                    name={[...MASQ_PATH, 'content']}
-                  >
-                    <Input.TextArea autoSize={{ minRows: 3 }} />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.headers')}
-                    name={[...MASQ_PATH, 'headers']}
-                  >
-                    <HeaderMapEditor mode="v1" />
-                  </Form.Item>
-                </>
-              )}
+                <Switch />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.skipTlsVerify')}
+                name={[...MASQ_PATH, 'insecure']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
             </>
-          );
-        }}
-      </Form.Item>
+          )}
+          {masqType === 'file' && (
+            <FormField
+              label={t('pages.inbounds.form.directory')}
+              name={[...MASQ_PATH, 'dir']}
+            >
+              <Input placeholder="/var/www/html" />
+            </FormField>
+          )}
+          {masqType === 'string' && (
+            <>
+              <FormField
+                label={t('pages.inbounds.form.statusCode')}
+                name={[...MASQ_PATH, 'statusCode']}
+              >
+                <InputNumber min={0} max={599} style={{ width: '100%' }} />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.body')}
+                name={[...MASQ_PATH, 'content']}
+              >
+                <Input.TextArea autoSize={{ minRows: 3 }} />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.headers')}
+                name={[...MASQ_PATH, 'headers']}
+              >
+                <HeaderMapEditor mode="v1" />
+              </FormField>
+            </>
+          )}
+        </>
+      )}
     </>
   );
 }

+ 9 - 8
frontend/src/pages/inbounds/form/protocols/mixed.tsx

@@ -1,6 +1,7 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Select, Switch } from 'antd';
+import { Input, Select, Switch } from 'antd';
 
+import { FormField } from '@/components/form/rhf';
 import AccountsList from './accounts-list';
 
 export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
@@ -8,25 +9,25 @@ export default function MixedFields({ mixedUdpOn }: { mixedUdpOn: boolean }) {
   return (
     <>
       <AccountsList />
-      <Form.Item name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
+      <FormField name={['settings', 'auth']} label={t('pages.inbounds.info.auth')}>
         <Select
           options={[
             { value: 'noauth', label: 'noauth' },
             { value: 'password', label: 'password' },
           ]}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'udp']}
         label="UDP"
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
       {mixedUdpOn && (
-        <Form.Item name={['settings', 'ip']} label="UDP IP">
+        <FormField name={['settings', 'ip']} label="UDP IP">
           <Input />
-        </Form.Item>
+        </FormField>
       )}
     </>
   );

+ 32 - 30
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -1,47 +1,49 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
+import { FormField } from '@/components/form/rhf';
 import { useOutboundTags } from '@/api/queries/useOutboundTags';
 
 export default function MtprotoFields() {
   const { t } = useTranslation();
-  const form = Form.useFormInstance();
-  const routeThroughXray = Form.useWatch(['settings', 'routeThroughXray'], form) as boolean | undefined;
+  const { control } = useFormContext();
+  const routeThroughXray = useWatch({ control, name: 'settings.routeThroughXray' }) as boolean | undefined;
   const { data: outboundTags } = useOutboundTags();
   return (
     <>
-      <Form.Item
+      <FormField
         name={['settings', 'fakeTlsDomain']}
         label={t('pages.inbounds.form.fakeTlsDomain')}
         tooltip={t('pages.inbounds.form.mtprotoFakeTlsDomainHint')}
       >
         <Input placeholder="www.cloudflare.com" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'domainFronting', 'ip']}
         label={t('pages.inbounds.form.mtgDomainFrontingIp')}
         tooltip={t('pages.inbounds.form.mtgDomainFrontingHint')}
       >
         <Input placeholder="127.0.0.1" />
-      </Form.Item>
-      <Form.Item name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
+      </FormField>
+      <FormField name={['settings', 'domainFronting', 'port']} label={t('pages.inbounds.form.mtgDomainFrontingPort')}>
         <InputNumber min={0} max={65535} placeholder="443" style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'domainFronting', 'proxyProtocol']}
         label={t('pages.inbounds.form.mtgDomainFrontingProxyProtocol')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'proxyProtocolListener']}
         label={t('pages.inbounds.form.mtgProxyProtocolListener')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
+      </FormField>
+      <FormField name={['settings', 'preferIp']} label={t('pages.inbounds.form.mtgPreferIp')}>
         <Select
           allowClear
           placeholder="prefer-ipv6"
@@ -52,27 +54,27 @@ export default function MtprotoFields() {
             { value: 'only-ipv4', label: 'only-ipv4' },
           ]}
         />
-      </Form.Item>
-      <Form.Item name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valuePropName="checked">
+      </FormField>
+      <FormField name={['settings', 'debug']} label={t('pages.inbounds.form.mtgDebug')} valueProp="checked">
         <Switch />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'throttleMaxConnections']}
         label={t('pages.inbounds.form.mtgThrottleMaxConnections')}
         tooltip={t('pages.inbounds.form.mtgThrottleMaxConnectionsHint')}
       >
         <InputNumber min={0} placeholder="0" style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'routeThroughXray']}
         label={t('pages.inbounds.form.mtgRouteThroughXray')}
         tooltip={t('pages.inbounds.form.mtgRouteThroughXrayHint')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
       {routeThroughXray && (
-        <Form.Item
+        <FormField
           name={['settings', 'outboundTag']}
           label={t('pages.inbounds.form.mtgRouteOutbound')}
           tooltip={t('pages.inbounds.form.mtgRouteOutboundHint')}
@@ -83,22 +85,22 @@ export default function MtprotoFields() {
             placeholder={t('pages.inbounds.form.mtgRouteOutboundPlaceholder')}
             options={(outboundTags ?? []).map((tag) => ({ value: tag, label: tag }))}
           />
-        </Form.Item>
+        </FormField>
       )}
-      <Form.Item
+      <FormField
         name={['settings', 'publicIpv4']}
         label={t('pages.inbounds.form.mtgPublicIpv4')}
         tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
       >
         <Input allowClear placeholder="1.2.3.4" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'publicIpv6']}
         label={t('pages.inbounds.form.mtgPublicIpv6')}
         tooltip={t('pages.inbounds.form.mtgPublicIpHint')}
       >
         <Input allowClear placeholder="2001:db8::1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 23 - 22
frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx

@@ -1,44 +1,45 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Select, Space, Switch, type FormInstance } from 'antd';
+import { Button, Form, Input, Select, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
+import { useFormContext } from 'react-hook-form';
 
 import { RandomUtil } from '@/utils';
+import { FormField } from '@/components/form/rhf';
 import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
 
 interface ShadowsocksFieldsProps {
-  form: FormInstance<InboundFormValues>;
   isSSWith2022: boolean;
 }
 
-export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFieldsProps) {
+export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
   const { t } = useTranslation();
+  const { getValues, setValue } = useFormContext();
   return (
     <>
-      <Form.Item name={['settings', 'method']} label={t('pages.inbounds.form.encryptionMethod')}>
+      <FormField
+        name={['settings', 'method']}
+        label={t('pages.inbounds.form.encryptionMethod')}
+        onAfterChange={(v) => {
+          setValue('settings.password', RandomUtil.randomShadowsocksPassword(v as string));
+        }}
+      >
         <Select
-          onChange={(v) => {
-            form.setFieldValue(
-              ['settings', 'password'],
-              RandomUtil.randomShadowsocksPassword(v as string),
-            );
-          }}
           options={SSMethodSchema.options.map((m) => ({ value: m, label: m }))}
         />
-      </Form.Item>
+      </FormField>
       {isSSWith2022 && (
         <Form.Item label={t('password')}>
           <Space.Compact block>
-            <Form.Item name={['settings', 'password']} noStyle>
+            <FormField name={['settings', 'password']} noStyle>
               <Input style={{ width: 'calc(100% - 32px)' }} />
-            </Form.Item>
+            </FormField>
             <Button
               aria-label={t('regenerate')}
               icon={<ReloadOutlined />}
               onClick={() => {
-                const method = form.getFieldValue(['settings', 'method']);
-                form.setFieldValue(
-                  ['settings', 'password'],
+                const method = getValues('settings.method');
+                setValue(
+                  'settings.password',
                   RandomUtil.randomShadowsocksPassword(method as string),
                 );
               }}
@@ -46,7 +47,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
           </Space.Compact>
         </Form.Item>
       )}
-      <Form.Item name={['settings', 'network']} label={t('pages.inbounds.network')}>
+      <FormField name={['settings', 'network']} label={t('pages.inbounds.network')}>
         <Select
           style={{ width: 120 }}
           options={[
@@ -55,14 +56,14 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
             { value: 'udp', label: 'UDP' },
           ]}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'ivCheck']}
         label="ivCheck"
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 60 - 71
frontend/src/pages/inbounds/form/protocols/tun.tsx

@@ -1,84 +1,73 @@
+import type { ReactNode } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Space, Tooltip } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
+
+import { FormField } from '@/components/form/rhf';
+
+interface StringListProps {
+  name: string[];
+  label: ReactNode;
+  placeholder: (index: number) => string;
+}
+
+function StringList({ name, label, placeholder }: StringListProps) {
+  const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
+  return (
+    <Form.Item label={label}>
+      <Button aria-label={t('add')} size="small" onClick={() => append('')}>
+        <PlusOutlined />
+      </Button>
+      {fields.map((field, j) => (
+        <Space.Compact key={field.id} block className="mt-4">
+          <FormField name={[...name, j]} noStyle>
+            <Input placeholder={placeholder(j)} />
+          </FormField>
+          <Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
+            <MinusOutlined />
+          </Button>
+        </Space.Compact>
+      ))}
+    </Form.Item>
+  );
+}
 
 export default function TunFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
+      <FormField name={['settings', 'name']} label={t('pages.inbounds.info.interfaceName')}>
         <Input placeholder="xray0" />
-      </Form.Item>
-      <Form.Item name={['settings', 'mtu']} label="MTU">
+      </FormField>
+      <FormField name={['settings', 'mtu']} label="MTU">
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.List name={['settings', 'gateway']}>
-        {(fields, { add, remove }) => (
-          <Form.Item label={t('pages.inbounds.info.gateway')}>
-            <Button aria-label={t('add')} size="small" onClick={() => add('')}>
-              <PlusOutlined />
-            </Button>
-            {fields.map((field, j) => (
-              <Space.Compact key={field.key} block className="mt-4">
-                <Form.Item name={field.name} noStyle>
-                  <Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
-                </Form.Item>
-                <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
-                  <MinusOutlined />
-                </Button>
-              </Space.Compact>
-            ))}
-          </Form.Item>
-        )}
-      </Form.List>
-      <Form.List name={['settings', 'dns']}>
-        {(fields, { add, remove }) => (
-          <Form.Item label="DNS">
-            <Button aria-label={t('add')} size="small" onClick={() => add('')}>
-              <PlusOutlined />
-            </Button>
-            {fields.map((field, j) => (
-              <Space.Compact key={field.key} block className="mt-4">
-                <Form.Item name={field.name} noStyle>
-                  <Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
-                </Form.Item>
-                <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
-                  <MinusOutlined />
-                </Button>
-              </Space.Compact>
-            ))}
-          </Form.Item>
-        )}
-      </Form.List>
-      <Form.Item name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
+      </FormField>
+      <StringList
+        name={['settings', 'gateway']}
+        label={t('pages.inbounds.info.gateway')}
+        placeholder={(j) => (j === 0 ? '10.0.0.1/16' : 'fc00::1/64')}
+      />
+      <StringList
+        name={['settings', 'dns']}
+        label="DNS"
+        placeholder={(j) => (j === 0 ? '1.1.1.1' : '8.8.8.8')}
+      />
+      <FormField name={['settings', 'userLevel']} label={t('pages.xray.tun.userLevel')}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.List name={['settings', 'autoSystemRoutingTable']}>
-        {(fields, { add, remove }) => (
-          <Form.Item
-            label={
-              <Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
-                {t('pages.inbounds.info.autoSystemRoutes')}
-              </Tooltip>
-            }
-          >
-            <Button aria-label={t('add')} size="small" onClick={() => add('')}>
-              <PlusOutlined />
-            </Button>
-            {fields.map((field, j) => (
-              <Space.Compact key={field.key} block className="mt-4">
-                <Form.Item name={field.name} noStyle>
-                  <Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
-                </Form.Item>
-                <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
-                  <MinusOutlined />
-                </Button>
-              </Space.Compact>
-            ))}
-          </Form.Item>
-        )}
-      </Form.List>
-      <Form.Item
+      </FormField>
+      <StringList
+        name={['settings', 'autoSystemRoutingTable']}
+        label={
+          <Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
+            {t('pages.inbounds.info.autoSystemRoutes')}
+          </Tooltip>
+        }
+        placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
+      />
+      <FormField
         name={['settings', 'autoOutboundsInterface']}
         label={
           <Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
@@ -87,7 +76,7 @@ export default function TunFields() {
         }
       >
         <Input placeholder="auto" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 13 - 12
frontend/src/pages/inbounds/form/protocols/tunnel.tsx

@@ -1,19 +1,20 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function TunnelFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
+      <FormField name={['settings', 'rewriteAddress']} label={t('pages.inbounds.form.rewriteAddress')}>
         <Input />
-      </Form.Item>
-      <Form.Item name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
+      </FormField>
+      <FormField name={['settings', 'rewritePort']} label={t('pages.inbounds.form.rewritePort')}>
         <InputNumber min={0} max={65535} />
-      </Form.Item>
-      <Form.Item name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
+      </FormField>
+      <FormField name={['settings', 'allowedNetwork']} label={t('pages.inbounds.form.allowedNetwork')}>
         <Select
           options={[
             { value: 'tcp,udp', label: 'TCP, UDP' },
@@ -21,17 +22,17 @@ export default function TunnelFields() {
             { value: 'udp', label: 'UDP' },
           ]}
         />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
+      </FormField>
+      <FormField label={t('pages.inbounds.portMap')} name={['settings', 'portMap']}>
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'followRedirect']}
         label={t('pages.inbounds.form.followRedirect')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 23 - 7
frontend/src/pages/inbounds/form/protocols/vless.tsx

@@ -1,7 +1,9 @@
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
 
+import { FormField } from '@/components/form/rhf';
 import { VLESS_AUTH_LABEL_KEYS, type VlessAuthKind } from '@/lib/xray/vless-encryption';
 
 interface VlessFieldsProps {
@@ -24,6 +26,7 @@ export default function VlessFields({
   clearVlessEnc,
 }: VlessFieldsProps) {
   const { t } = useTranslation();
+  const { control } = useFormContext();
   const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
 
   useEffect(() => {
@@ -36,12 +39,12 @@ export default function VlessFields({
 
   return (
     <>
-      <Form.Item name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
+      <FormField name={['settings', 'decryption']} label={t('pages.inbounds.decryption')}>
         <Input />
-      </Form.Item>
-      <Form.Item name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
+      </FormField>
+      <FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
         <Input />
-      </Form.Item>
+      </FormField>
       <Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
         <Space size={8} wrap>
           <Select
@@ -66,9 +69,22 @@ export default function VlessFields({
         >
           <Space.Compact block>
             {[900, 500, 900, 256].map((def, i) => (
-              <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
-                <InputNumber min={1} style={{ width: '25%' }} />
-              </Form.Item>
+              <Controller
+                key={i}
+                control={control}
+                name={`settings.testseed.${i}`}
+                defaultValue={def}
+                render={({ field }) => (
+                  <InputNumber
+                    min={1}
+                    style={{ width: '25%' }}
+                    value={field.value as number}
+                    onChange={field.onChange}
+                    onBlur={field.onBlur}
+                    ref={field.ref}
+                  />
+                )}
+              />
             ))}
           </Space.Compact>
         </Form.Item>

+ 13 - 11
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 
+import { FormField } from '@/components/form/rhf';
+
 interface WireguardFieldsProps {
   wgPubKey: string;
   regenInboundWg: () => void;
@@ -13,29 +15,29 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
     <>
       <Form.Item label={t('pages.xray.wireguard.secretKey')}>
         <Space.Compact block>
-          <Form.Item name={['settings', 'secretKey']} noStyle>
+          <FormField name={['settings', 'secretKey']} noStyle>
             <Input style={{ width: 'calc(100% - 32px)' }} />
-          </Form.Item>
+          </FormField>
           <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
         </Space.Compact>
       </Form.Item>
       <Form.Item label={t('pages.xray.wireguard.publicKey')}>
         <Input value={wgPubKey} disabled />
       </Form.Item>
-      <Form.Item name={['settings', 'mtu']} label="MTU">
+      <FormField name={['settings', 'mtu']} label="MTU">
         <InputNumber />
-      </Form.Item>
-      <Form.Item name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
+      </FormField>
+      <FormField name={['settings', 'dns']} label={t('pages.inbounds.info.dns')}>
         <Input placeholder="1.1.1.1, 1.0.0.1" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['settings', 'noKernelTun']}
         label={t('pages.inbounds.info.noKernelTun')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
+      </FormField>
+      <FormField name={['settings', 'domainStrategy']} label={t('pages.xray.wireguard.domainStrategy')}>
         <Select
           allowClear
           options={[
@@ -46,7 +48,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
             { value: 'ForceIPv6v4', label: 'ForceIPv6v4' },
           ]}
         />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 43 - 44
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
 import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
 
+import { FormField } from '@/components/form/rhf';
 import { UTLS_FINGERPRINT } from '@/schemas/primitives';
 import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
 import type { RealityScanResult } from '@/generated/types';
@@ -41,43 +42,41 @@ export default function RealityForm({
   const [scannerOpen, setScannerOpen] = useState(false);
   return (
     <>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'realitySettings', 'show']}
         label={t('pages.inbounds.form.show')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
+      </FormField>
+      <FormField name={['streamSettings', 'realitySettings', 'xver']} label={t('pages.inbounds.form.xver')}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'settings', 'fingerprint']}
         label="uTLS"
       >
         <Select
           options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
         />
-      </Form.Item>
+      </FormField>
       <Form.Item
         label={t('pages.inbounds.form.target')}
         tooltip={t('pages.inbounds.form.realityTargetHint')}
       >
         <Space.Compact block style={{ display: 'flex' }}>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'realitySettings', 'target']}
             noStyle
-            rules={[
-              {
-                validator: async (_, value) => {
-                  const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
-                  if (errKey) throw new Error(t(errKey));
-                },
+            rules={{
+              validate: (value) => {
+                const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
+                return errKey ? errKey : true;
               },
-            ]}
+            }}
           >
             <Input style={{ flex: 1 }} placeholder="example.com:443" />
-          </Form.Item>
+          </FormField>
           <Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
             {t('pages.inbounds.form.scan')}
           </Button>
@@ -116,35 +115,35 @@ export default function RealityForm({
           />
         </Form.Item>
       )}
-      <Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
+      <FormField label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
         <Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'maxTimediff']}
         label={t('pages.inbounds.form.maxTimeDiff')}
       >
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'minClientVer']}
         label={t('pages.inbounds.form.minClientVer')}
       >
         <Input placeholder="25.9.11" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'maxClientVer']}
         label={t('pages.inbounds.form.maxClientVer')}
       >
         <Input placeholder="25.9.11" />
-      </Form.Item>
+      </FormField>
       <Form.Item label={t('pages.inbounds.form.shortIds')}>
         <Space.Compact block style={{ display: 'flex' }}>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'realitySettings', 'shortIds']}
             noStyle
           >
             <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
-          </Form.Item>
+          </FormField>
           <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
         </Space.Compact>
       </Form.Item>
@@ -153,27 +152,27 @@ export default function RealityForm({
         tooltip={t('pages.inbounds.form.spiderXHint')}
       >
         <Space.Compact block style={{ display: 'flex' }}>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'realitySettings', 'settings', 'spiderX']}
             noStyle
           >
             <Input style={{ flex: 1 }} />
-          </Form.Item>
+          </FormField>
           <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeSpiderX} />
         </Space.Compact>
       </Form.Item>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
         label={t('pages.inbounds.publicKey')}
       >
         <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'privateKey']}
         label={t('pages.inbounds.privatekey')}
       >
         <Input.TextArea autoSize={{ minRows: 1, maxRows: 4 }} />
-      </Form.Item>
+      </FormField>
       <Form.Item label=" ">
         <Space>
           <Button type="primary" loading={saving} onClick={genRealityKeypair}>
@@ -182,18 +181,18 @@ export default function RealityForm({
           <Button danger onClick={clearRealityKeypair}>{t('clear')}</Button>
         </Space>
       </Form.Item>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'realitySettings', 'mldsa65Seed']}
         label={t('pages.inbounds.form.mldsa65Seed')}
       >
         <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify']}
         label={t('pages.inbounds.form.mldsa65Verify')}
       >
         <Input.TextArea autoSize={{ minRows: 2, maxRows: 6 }} />
-      </Form.Item>
+      </FormField>
       <Form.Item label=" ">
         <Space>
           <Button type="primary" loading={saving} onClick={genMldsa65}>
@@ -202,13 +201,13 @@ export default function RealityForm({
           <Button danger onClick={clearMldsa65}>{t('clear')}</Button>
         </Space>
       </Form.Item>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'realitySettings', 'masterKeyLog']}
         label={t('pages.inbounds.form.masterKeyLog')}
         tooltip={t('pages.inbounds.form.masterKeyLogTip')}
       >
         <Input placeholder="/path/to/sslkeylog.txt" />
-      </Form.Item>
+      </FormField>
       <Collapse
         style={{ marginBottom: 14 }}
         items={[
@@ -222,27 +221,27 @@ export default function RealityForm({
                     <Divider style={{ margin: '0 0 14px 0' }}>
                       {t(`pages.inbounds.form.${dir}`)}
                     </Divider>
-                    <Form.Item
+                    <FormField
                       name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
                       label={t('pages.inbounds.form.afterBytes')}
                       tooltip={t('pages.inbounds.form.afterBytesTip')}
                     >
                       <InputNumber min={0} />
-                    </Form.Item>
-                    <Form.Item
+                    </FormField>
+                    <FormField
                       name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
                       label={t('pages.inbounds.form.bytesPerSec')}
                       tooltip={t('pages.inbounds.form.bytesPerSecTip')}
                     >
                       <InputNumber min={0} />
-                    </Form.Item>
-                    <Form.Item
+                    </FormField>
+                    <FormField
                       name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
                       label={t('pages.inbounds.form.burstBytesPerSec')}
                       tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
                     >
                       <InputNumber min={0} />
-                    </Form.Item>
+                    </FormField>
                   </div>
                 ))}
               </>

+ 249 - 262
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -1,7 +1,9 @@
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
 import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 
+import { FormField } from '@/components/form/rhf';
 import {
   ALPN_OPTION,
   DOMAIN_STRATEGY_OPTION,
@@ -14,6 +16,11 @@ import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'
 
 const { TextArea } = Input;
 
+const CERT_LINES_TRANSFORM = {
+  input: (v: unknown) => (Array.isArray(v) ? v.join('\n') : v),
+  output: (raw: unknown) => (typeof raw === 'string' ? raw.split('\n') : raw),
+};
+
 interface TlsFormProps {
   saving: boolean;
   setCertFromPanel: (certName: number) => void;
@@ -24,6 +31,178 @@ interface TlsFormProps {
   clearEchCert: () => void;
 }
 
+interface CertRowProps {
+  index: number;
+  total: number;
+  saving: boolean;
+  onRemove: () => void;
+  setCertFromPanel: (certName: number) => void;
+  clearCertFiles: (certName: number) => void;
+}
+
+function CertRow({ index, total, saving, onRemove, setCertFromPanel, clearCertFiles }: CertRowProps) {
+  const { t } = useTranslation();
+  const { control } = useFormContext();
+  const useFile = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.useFile` });
+  const usage = useWatch({ control, name: `streamSettings.tlsSettings.certificates.${index}.usage` });
+  return (
+    <div>
+      <FormField
+        name={['streamSettings', 'tlsSettings', 'certificates', index, 'useFile']}
+        label={`${t('certificate')} ${index + 1}`}
+      >
+        <Radio.Group buttonStyle="solid">
+          <Radio.Button value={true}>
+            {t('pages.inbounds.certificatePath')}
+          </Radio.Button>
+          <Radio.Button value={false}>
+            {t('pages.inbounds.certificateContent')}
+          </Radio.Button>
+        </Radio.Group>
+      </FormField>
+      {total > 1 && (
+        <Form.Item label=" ">
+          <Button size="small" danger onClick={onRemove}>
+            <MinusOutlined /> {t('remove')}
+          </Button>
+        </Form.Item>
+      )}
+      {useFile ? (
+        <>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificateFile']}
+            label={t('pages.inbounds.publicKey')}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'certificates', index, 'keyFile']}
+            label={t('pages.inbounds.privatekey')}
+          >
+            <Input />
+          </FormField>
+          <Form.Item label=" ">
+            <Space>
+              <Button
+                type="primary"
+                loading={saving}
+                onClick={() => setCertFromPanel(index)}
+              >
+                {t('pages.inbounds.setDefaultCert')}
+              </Button>
+              <Button danger onClick={() => clearCertFiles(index)}>
+                {t('clear')}
+              </Button>
+            </Space>
+          </Form.Item>
+        </>
+      ) : (
+        <>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'certificates', index, 'certificate']}
+            label={t('pages.inbounds.publicKey')}
+            transform={CERT_LINES_TRANSFORM}
+          >
+            <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'certificates', index, 'key']}
+            label={t('pages.inbounds.privatekey')}
+            transform={CERT_LINES_TRANSFORM}
+          >
+            <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
+          </FormField>
+        </>
+      )}
+      <FormField
+        name={['streamSettings', 'tlsSettings', 'certificates', index, 'ocspStapling']}
+        label="OCSP Stapling"
+      >
+        <InputNumber min={0} suffix="s" style={{ width: '50%' }} />
+      </FormField>
+      <FormField
+        name={['streamSettings', 'tlsSettings', 'certificates', index, 'oneTimeLoading']}
+        label={t('pages.inbounds.form.oneTimeLoading')}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+      <FormField
+        name={['streamSettings', 'tlsSettings', 'certificates', index, 'usage']}
+        label={t('pages.inbounds.form.usageOption')}
+      >
+        <Select
+          style={{ width: '50%' }}
+          options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
+        />
+      </FormField>
+      {usage === 'issue' && (
+        <FormField
+          name={['streamSettings', 'tlsSettings', 'certificates', index, 'buildChain']}
+          label={t('pages.inbounds.form.buildChain')}
+          valueProp="checked"
+        >
+          <Switch />
+        </FormField>
+      )}
+    </div>
+  );
+}
+
+function EchSockoptSection() {
+  const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
+  const on = !!echSockopt;
+  return (
+    <>
+      <Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
+        <Switch
+          checked={on}
+          onChange={(v) =>
+            setValue(
+              'streamSettings.tlsSettings.echSockopt',
+              v ? SockoptStreamSettingsSchema.parse({}) : undefined,
+            )
+          }
+        />
+      </Form.Item>
+      {on && (
+        <>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
+            label={t('pages.inbounds.form.dialerProxy')}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
+            label={t('pages.xray.wireguard.domainStrategy')}
+          >
+            <Select
+              options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
+            />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
+            label={t('pages.inbounds.form.tcpFastOpen')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
+            label={t('pages.inbounds.form.multipathTcp')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+        </>
+      )}
+    </>
+  );
+}
+
 export default function TlsForm({
   saving,
   setCertFromPanel,
@@ -34,36 +213,41 @@ export default function TlsForm({
   clearEchCert,
 }: TlsFormProps) {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({
+    control,
+    name: 'streamSettings.tlsSettings.certificates',
+  });
   return (
     <>
-      <Form.Item name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
+      <FormField name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
         <Input placeholder={t('pages.inbounds.form.serverNameIndication')} />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
+      </FormField>
+      <FormField name={['streamSettings', 'tlsSettings', 'cipherSuites']} label={t('pages.inbounds.form.cipherSuites')}>
         <Select
           options={[
             { value: '', label: t('pages.inbounds.form.autoOption') },
             ...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
           ]}
         />
-      </Form.Item>
+      </FormField>
       <Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
         <Space.Compact block>
-          <Form.Item name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
+          <FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
             <Select
               style={{ width: '50%' }}
               options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
             />
-          </Form.Item>
-          <Form.Item name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
+          </FormField>
+          <FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
             <Select
               style={{ width: '50%' }}
               options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
             />
-          </Form.Item>
+          </FormField>
         </Space.Compact>
       </Form.Item>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'tlsSettings', 'settings', 'fingerprint']}
         label="uTLS"
       >
@@ -73,16 +257,16 @@ export default function TlsForm({
             ...Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp })),
           ]}
         />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
+      </FormField>
+      <FormField name={['streamSettings', 'tlsSettings', 'alpn']} label="ALPN">
         <Select
           mode="multiple"
           tokenSeparators={[',']}
           style={{ width: '100%' }}
           options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'tlsSettings', 'curvePreferences']}
         label={t('pages.inbounds.form.curvePreferences')}
         tooltip={t('pages.inbounds.form.curvePreferencesTip')}
@@ -96,274 +280,77 @@ export default function TlsForm({
             label: c,
           }))}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
         label={t('pages.inbounds.form.rejectUnknownSni')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'tlsSettings', 'disableSystemRoot']}
         label={t('pages.inbounds.form.disableSystemRoot')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'tlsSettings', 'enableSessionResumption']}
         label={t('pages.inbounds.form.sessionResumption')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
 
-      <Form.List name={['streamSettings', 'tlsSettings', 'certificates']}>
-        {(certFields, { add, remove }) => (
-          <>
-            <Form.Item label={t('certificate')}>
-              <Button
-                aria-label={t('add')}
-                type="primary"
-                size="small"
-                onClick={() => add({
-                  useFile: true,
-                  certificateFile: '',
-                  keyFile: '',
-                  certificate: [],
-                  key: [],
-                  ocspStapling: 0,
-                  oneTimeLoading: false,
-                  usage: 'encipherment',
-                  buildChain: false,
-                })}
-              >
-                <PlusOutlined />
-              </Button>
-            </Form.Item>
-            {certFields.map((certField, idx) => (
-              <div key={certField.key}>
-                <Form.Item
-                  name={[certField.name, 'useFile']}
-                  label={`${t('certificate')} ${idx + 1}`}
-                >
-                  <Radio.Group buttonStyle="solid">
-                    <Radio.Button value={true}>
-                      {t('pages.inbounds.certificatePath')}
-                    </Radio.Button>
-                    <Radio.Button value={false}>
-                      {t('pages.inbounds.certificateContent')}
-                    </Radio.Button>
-                  </Radio.Group>
-                </Form.Item>
-                {certFields.length > 1 && (
-                  <Form.Item label=" ">
-                    <Button
-                      size="small"
-                      danger
-                      onClick={() => remove(certField.name)}
-                    >
-                      <MinusOutlined /> {t('remove')}
-                    </Button>
-                  </Form.Item>
-                )}
-                <Form.Item
-                  noStyle
-                  shouldUpdate={(prev, curr) =>
-                    prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
-                    !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.useFile
-                  }
-                >
-                  {({ getFieldValue }) => {
-                    const useFile = getFieldValue([
-                      'streamSettings', 'tlsSettings', 'certificates',
-                      certField.name, 'useFile',
-                    ]);
-                    return useFile ? (
-                      <>
-                        <Form.Item
-                          name={[certField.name, 'certificateFile']}
-                          label={t('pages.inbounds.publicKey')}
-                        >
-                          <Input />
-                        </Form.Item>
-                        <Form.Item
-                          name={[certField.name, 'keyFile']}
-                          label={t('pages.inbounds.privatekey')}
-                        >
-                          <Input />
-                        </Form.Item>
-                        <Form.Item label=" ">
-                          <Space>
-                            <Button
-                              type="primary"
-                              loading={saving}
-                              onClick={() => setCertFromPanel(certField.name)}
-                            >
-                              {t('pages.inbounds.setDefaultCert')}
-                            </Button>
-                            <Button danger onClick={() => clearCertFiles(certField.name)}>
-                              {t('clear')}
-                            </Button>
-                          </Space>
-                        </Form.Item>
-                      </>
-                    ) : (
-                      <>
-                        <Form.Item
-                          name={[certField.name, 'certificate']}
-                          label={t('pages.inbounds.publicKey')}
-                          normalize={(v) => typeof v === 'string'
-                            ? v.split('\n')
-                            : v}
-                          getValueProps={(v) => ({
-                            value: Array.isArray(v) ? v.join('\n') : v,
-                          })}
-                        >
-                          <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
-                        </Form.Item>
-                        <Form.Item
-                          name={[certField.name, 'key']}
-                          label={t('pages.inbounds.privatekey')}
-                          normalize={(v) => typeof v === 'string'
-                            ? v.split('\n')
-                            : v}
-                          getValueProps={(v) => ({
-                            value: Array.isArray(v) ? v.join('\n') : v,
-                          })}
-                        >
-                          <TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
-                        </Form.Item>
-                      </>
-                    );
-                  }}
-                </Form.Item>
-                <Form.Item
-                  name={[certField.name, 'ocspStapling']}
-                  label="OCSP Stapling"
-                >
-                  <InputNumber min={0} suffix="s" style={{ width: '50%' }} />
-                </Form.Item>
-                <Form.Item
-                  name={[certField.name, 'oneTimeLoading']}
-                  label={t('pages.inbounds.form.oneTimeLoading')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  name={[certField.name, 'usage']}
-                  label={t('pages.inbounds.form.usageOption')}
-                >
-                  <Select
-                    style={{ width: '50%' }}
-                    options={Object.values(USAGE_OPTION).map((u) => ({ value: u, label: u }))}
-                  />
-                </Form.Item>
-                <Form.Item
-                  noStyle
-                  shouldUpdate={(prev, curr) =>
-                    prev.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
-                    !== curr.streamSettings?.tlsSettings?.certificates?.[certField.name]?.usage
-                  }
-                >
-                  {({ getFieldValue }) => {
-                    const usage = getFieldValue([
-                      'streamSettings', 'tlsSettings', 'certificates',
-                      certField.name, 'usage',
-                    ]);
-                    if (usage !== 'issue') return null;
-                    return (
-                      <Form.Item
-                        name={[certField.name, 'buildChain']}
-                        label={t('pages.inbounds.form.buildChain')}
-                        valuePropName="checked"
-                      >
-                        <Switch />
-                      </Form.Item>
-                    );
-                  }}
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
-      <Form.Item
+      <Form.Item label={t('certificate')}>
+        <Button
+          aria-label={t('add')}
+          type="primary"
+          size="small"
+          onClick={() => append({
+            useFile: true,
+            certificateFile: '',
+            keyFile: '',
+            certificate: [],
+            key: [],
+            ocspStapling: 0,
+            oneTimeLoading: false,
+            usage: 'encipherment',
+            buildChain: false,
+          })}
+        >
+          <PlusOutlined />
+        </Button>
+      </Form.Item>
+      {fields.map((field, idx) => (
+        <CertRow
+          key={field.id}
+          index={idx}
+          total={fields.length}
+          saving={saving}
+          onRemove={() => remove(idx)}
+          setCertFromPanel={setCertFromPanel}
+          clearCertFiles={clearCertFiles}
+        />
+      ))}
+      <FormField
         name={['streamSettings', 'tlsSettings', 'masterKeyLog']}
         label={t('pages.inbounds.form.masterKeyLog')}
         tooltip={t('pages.inbounds.form.masterKeyLogTip')}
       >
         <Input placeholder="/path/to/sslkeylog.txt" />
-      </Form.Item>
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) =>
-          !!(prev.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
-          !== !!(curr.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
-        }
-      >
-        {({ getFieldValue, setFieldValue }) => {
-          const on = !!getFieldValue(['streamSettings', 'tlsSettings', 'echSockopt']);
-          return (
-            <>
-              <Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
-                <Switch
-                  checked={on}
-                  onChange={(v) =>
-                    setFieldValue(
-                      ['streamSettings', 'tlsSettings', 'echSockopt'],
-                      v ? SockoptStreamSettingsSchema.parse({}) : undefined,
-                    )
-                  }
-                />
-              </Form.Item>
-              {on && (
-                <>
-                  <Form.Item
-                    name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
-                    label={t('pages.inbounds.form.dialerProxy')}
-                  >
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
-                    label={t('pages.xray.wireguard.domainStrategy')}
-                  >
-                    <Select
-                      options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
-                    />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
-                    label={t('pages.inbounds.form.tcpFastOpen')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
-                    label={t('pages.inbounds.form.multipathTcp')}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                </>
-              )}
-            </>
-          );
-        }}
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
+      </FormField>
+      <EchSockoptSection />
+      <FormField name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'tlsSettings', 'settings', 'echConfigList']}
         label={t('pages.inbounds.form.echConfig')}
       >
         <Input />
-      </Form.Item>
+      </FormField>
       <Form.Item label=" ">
         <Space>
           <Button type="primary" loading={saving} onClick={getNewEchCert}>
@@ -377,7 +364,7 @@ export default function TlsForm({
         tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
       >
         <Space.Compact block>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256']}
             noStyle
           >
@@ -387,7 +374,7 @@ export default function TlsForm({
               placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
               style={{ width: 'calc(100% - 64px)' }}
             />
-          </Form.Item>
+          </FormField>
           <Button
             icon={<FileProtectOutlined />}
             onClick={pinFromCert}
@@ -402,13 +389,13 @@ export default function TlsForm({
           />
         </Space.Compact>
       </Form.Item>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
         label={t('pages.inbounds.form.verifyPeerCertByName')}
         tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
       >
         <Input placeholder="example.com" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 10 - 8
frontend/src/pages/inbounds/form/transport/grpc.tsx

@@ -1,29 +1,31 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function GrpcForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'grpcSettings', 'serviceName']}
         label={t('pages.inbounds.form.serviceName')}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'grpcSettings', 'authority']}
         label={t('pages.inbounds.form.authority')}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'grpcSettings', 'multiMode']}
         label={t('pages.inbounds.form.multiMode')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 11 - 10
frontend/src/pages/inbounds/form/transport/httpupgrade.tsx

@@ -1,37 +1,38 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function HttpUpgradeForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'httpupgradeSettings', 'acceptProxyProtocol']}
         label={t('pages.inbounds.form.proxyProtocol')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'httpupgradeSettings', 'host']}
         label={t('host')}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'httpupgradeSettings', 'path']}
         label={t('path')}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.headers')}
         name={['streamSettings', 'httpupgradeSettings', 'headers']}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 15 - 13
frontend/src/pages/inbounds/form/transport/kcp.tsx

@@ -1,34 +1,36 @@
 import { useTranslation } from 'react-i18next';
-import { Form, InputNumber } from 'antd';
+import { InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function KcpForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
+      <FormField name={['streamSettings', 'kcpSettings', 'mtu']} label="MTU">
         <InputNumber min={576} max={1460} />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
+      </FormField>
+      <FormField name={['streamSettings', 'kcpSettings', 'tti']} label={t('pages.inbounds.form.ttiMs')}>
         <InputNumber min={10} max={100} />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
+      </FormField>
+      <FormField name={['streamSettings', 'kcpSettings', 'uplinkCapacity']} label={t('pages.inbounds.form.uplinkMbps')}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
+      </FormField>
+      <FormField name={['streamSettings', 'kcpSettings', 'downlinkCapacity']} label={t('pages.inbounds.form.downlinkMbps')}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
         label={t('pages.inbounds.form.cwndMultiplier')}
       >
         <InputNumber min={1} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
         label={t('pages.inbounds.form.maxSendingWindow')}
       >
         <InputNumber min={0} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 95 - 137
frontend/src/pages/inbounds/form/transport/raw.tsx

@@ -1,157 +1,115 @@
 import { useTranslation } from 'react-i18next';
 import { Form, Input, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function RawForm() {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const headerType = (useWatch({
+    control,
+    name: 'streamSettings.tcpSettings.header.type',
+  }) ?? 'none') as string;
   return (
     <>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'tcpSettings', 'acceptProxyProtocol']}
         label={t('pages.inbounds.form.proxyProtocol')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
       <Form.Item label={`HTTP ${t('camouflage')}`}>
-        <Form.Item
-          noStyle
-          shouldUpdate={(prev, curr) =>
-            prev.streamSettings?.tcpSettings?.header?.type
-            !== curr.streamSettings?.tcpSettings?.header?.type
-          }
-        >
-          {({ getFieldValue, setFieldValue }) => {
-            const headerType = getFieldValue(
-              ['streamSettings', 'tcpSettings', 'header', 'type'],
-            ) as string | undefined;
-            return (
-              <Switch
-                checked={headerType === 'http'}
-                onChange={(v) => {
-                  setFieldValue(
-                    ['streamSettings', 'tcpSettings', 'header'],
-                    v
-                      ? {
-                        type: 'http',
-                        request: {
-                          version: '1.1',
-                          method: 'GET',
-                          path: ['/'],
-                          headers: {},
-                        },
-                        response: {
-                          version: '1.1',
-                          status: '200',
-                          reason: 'OK',
-                          headers: {},
-                        },
-                      }
-                      : { type: 'none' },
-                  );
-                }}
-              />
+        <Switch
+          checked={headerType === 'http'}
+          onChange={(v) => {
+            setValue(
+              'streamSettings.tcpSettings.header',
+              v
+                ? {
+                  type: 'http',
+                  request: {
+                    version: '1.1',
+                    method: 'GET',
+                    path: ['/'],
+                    headers: {},
+                  },
+                  response: {
+                    version: '1.1',
+                    status: '200',
+                    reason: 'OK',
+                    headers: {},
+                  },
+                }
+                : { type: 'none' },
             );
           }}
-        </Form.Item>
-      </Form.Item>
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) =>
-          prev.streamSettings?.tcpSettings?.header?.type
-          !== curr.streamSettings?.tcpSettings?.header?.type
-        }
-      >
-        {({ getFieldValue }) => {
-          const headerType = getFieldValue(
-            ['streamSettings', 'tcpSettings', 'header', 'type'],
-          ) as string | undefined;
-          if (headerType !== 'http') return null;
-          return (
-            <>
-              <Form.Item
-                label={t('pages.inbounds.form.requestVersion')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'request', 'version',
-                ]}
-              >
-                <Input placeholder="1.1" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.requestMethod')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'request', 'method',
-                ]}
-              >
-                <Input placeholder="GET" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.requestPath')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'request', 'path',
-                ]}
-                getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
-                getValueFromEvent={(e) => {
-                  const raw = (e?.target?.value ?? '') as string;
-                  const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
-                  return parts.length > 0 ? parts : ['/'];
-                }}
-              >
-                <Input placeholder="/" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.requestHeaders')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'request', 'headers',
-                ]}
-              >
-                <HeaderMapEditor mode="v2" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.responseVersion')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'response', 'version',
-                ]}
-              >
-                <Input placeholder="1.1" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.responseStatus')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'response', 'status',
-                ]}
-              >
-                <Input placeholder="200" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.responseReason')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'response', 'reason',
-                ]}
-              >
-                <Input placeholder="OK" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.responseHeaders')}
-                name={[
-                  'streamSettings', 'tcpSettings', 'header',
-                  'response', 'headers',
-                ]}
-              >
-                <HeaderMapEditor mode="v2" />
-              </Form.Item>
-            </>
-          );
-        }}
+        />
       </Form.Item>
+      {headerType === 'http' && (
+        <>
+          <FormField
+            label={t('pages.inbounds.form.requestVersion')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
+          >
+            <Input placeholder="1.1" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestMethod')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
+          >
+            <Input placeholder="GET" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestPath')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
+            transform={{
+              input: (v) => (Array.isArray(v) ? v.join(',') : v),
+              output: (raw) => {
+                const parts = String(raw ?? '')
+                  .split(',')
+                  .map((s) => s.trim())
+                  .filter(Boolean);
+                return parts.length > 0 ? parts : ['/'];
+              },
+            }}
+          >
+            <Input placeholder="/" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestHeaders')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
+          >
+            <HeaderMapEditor mode="v2" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseVersion')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
+          >
+            <Input placeholder="1.1" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseStatus')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
+          >
+            <Input placeholder="200" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseReason')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
+          >
+            <Input placeholder="OK" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseHeaders')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
+          >
+            <HeaderMapEditor mode="v2" />
+          </FormField>
+        </>
+      )}
     </>
   );
 }

+ 189 - 232
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -1,19 +1,21 @@
 import { useTranslation } from 'react-i18next';
 import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
 
-import { CustomSockoptList } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
+import { SockoptCustomField } from '@/lib/xray/forms/fields';
 import { TCP_CONGESTION_OPTION } from '@/schemas/primitives';
 
-// Transport key that carries its own acceptProxyProtocol field (mirrored
-// alongside the sockopt-level one so the PROXY preset never silently no-ops).
+/* Transport key that carries its own acceptProxyProtocol field (mirrored
+   alongside the sockopt-level one so the PROXY preset never silently no-ops). */
 const TRANSPORT_PROXY_FIELD: Record<string, string> = {
   tcp: 'tcpSettings',
   ws: 'wsSettings',
   httpupgrade: 'httpupgradeSettings',
 };
-// Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
-// in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
-// instead of the old x-real-ip gRPC metadata.
+/* Transports on which xray-core honors sockopt.trustedXForwardedFor. gRPC joined
+   in v26.6.22 (xray-core 711aea4): it now reads X-Forwarded-For via this option
+   instead of the old x-real-ip gRPC metadata. */
 const TRUSTED_HEADER_NETWORKS = ['ws', 'httpupgrade', 'xhttp', 'grpc'];
 
 type RealClientIpPreset = 'off' | 'cloudflare' | 'proxy';
@@ -26,252 +28,207 @@ export default function SockoptForm({
   network: string;
 }) {
   const { t } = useTranslation();
+  const { control, getValues, setValue } = useFormContext();
+  const sock = useWatch({ control, name: 'streamSettings.sockopt' });
+  const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
 
-  // Presets write the same sockopt fields the user could set by hand below,
-  // picking the mechanism xray-core actually honors for the chosen transport:
-  // CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
-  // PROXY-protocol header via acceptProxyProtocol (every transport but mKCP).
-  const applyRealClientIpPreset = (
-    preset: RealClientIpPreset,
-    getFieldValue: (name: (string | number)[]) => unknown,
-    setFieldValue: (name: (string | number)[], value: unknown) => void,
-  ) => {
-    const sockopt = getFieldValue(['streamSettings', 'sockopt']);
+  const transportField = TRANSPORT_PROXY_FIELD[network];
+  const sockAcceptPP = useWatch({ control, name: 'streamSettings.sockopt.acceptProxyProtocol' });
+  const sockTrusted = useWatch({ control, name: 'streamSettings.sockopt.trustedXForwardedFor' });
+  const transportAcceptPP = useWatch({
+    control,
+    name: transportField ? `streamSettings.${transportField}.acceptProxyProtocol` : 'streamSettings.__noTransportProxyField',
+  });
+
+  /* Presets write the same sockopt fields the user could set by hand below,
+     picking the mechanism xray-core actually honors for the chosen transport:
+     CF-Connecting-IP via trustedXForwardedFor (ws/httpupgrade/xhttp/grpc) or the
+     PROXY-protocol header via acceptProxyProtocol (every transport but mKCP). */
+  const applyRealClientIpPreset = (preset: RealClientIpPreset) => {
+    const sockopt = getValues('streamSettings.sockopt');
     const sockoptOn =
       !!sockopt && typeof sockopt === 'object' && Object.keys(sockopt as object).length > 0;
     if (preset !== 'off' && !sockoptOn) {
       toggleSockopt(true);
     }
-    const transportField = TRANSPORT_PROXY_FIELD[network];
 
     if (preset === 'off') {
-      setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
-      setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
-      if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
+      setValue('streamSettings.sockopt.trustedXForwardedFor', []);
+      setValue('streamSettings.sockopt.acceptProxyProtocol', false);
+      if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
       return;
     }
 
     if (preset === 'cloudflare') {
-      const current = getFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor']);
+      const current = getValues('streamSettings.sockopt.trustedXForwardedFor');
       const list = Array.isArray(current) ? [...(current as string[])] : [];
       if (!list.includes('CF-Connecting-IP')) list.push('CF-Connecting-IP');
-      setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], list);
-      setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], false);
-      if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], false);
+      setValue('streamSettings.sockopt.trustedXForwardedFor', list);
+      setValue('streamSettings.sockopt.acceptProxyProtocol', false);
+      if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, false);
       return;
     }
 
-    // proxy — clear trustedXForwardedFor so a lingering header can't override the
-    // PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc).
-    setFieldValue(['streamSettings', 'sockopt', 'trustedXForwardedFor'], []);
-    setFieldValue(['streamSettings', 'sockopt', 'acceptProxyProtocol'], true);
-    if (transportField) setFieldValue(['streamSettings', transportField, 'acceptProxyProtocol'], true);
+    /* proxy — clear trustedXForwardedFor so a lingering header can't override the
+       PROXY-recovered IP (xray reads the header last on ws/httpupgrade/xhttp/grpc). */
+    setValue('streamSettings.sockopt.trustedXForwardedFor', []);
+    setValue('streamSettings.sockopt.acceptProxyProtocol', true);
+    if (transportField) setValue(`streamSettings.${transportField}.acceptProxyProtocol`, true);
   };
 
+  const transportPP = transportField ? transportAcceptPP === true : false;
+  const proxyOn = sockAcceptPP === true || transportPP;
+  const trusted = Array.isArray(sockTrusted) ? (sockTrusted as string[]) : [];
+  const presetValue: RealClientIpPreset = proxyOn
+    ? 'proxy'
+    : trusted.length > 0
+      ? 'cloudflare'
+      : 'off';
+  const trustedMismatch = trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
+  const proxyMismatch = proxyOn && network === 'kcp';
+
   return (
-    <Form.Item
-      noStyle
-      shouldUpdate={(prev, curr) => {
-        const a = (prev.streamSettings as { sockopt?: object } | undefined)?.sockopt;
-        const b = (curr.streamSettings as { sockopt?: object } | undefined)?.sockopt;
-        return !!a !== !!b;
-      }}
-    >
-      {({ getFieldValue }) => {
-        const sock = getFieldValue(['streamSettings', 'sockopt']);
-        const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
-        return (
-          <>
-            <Form.Item label="Sockopt">
-              <Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
-            </Form.Item>
-            {on && (
-              <>
-                <Form.Item
-                  noStyle
-                  shouldUpdate={(prev, curr) => {
-                    type ProxyWatch = {
-                      streamSettings?: {
-                        sockopt?: { trustedXForwardedFor?: unknown; acceptProxyProtocol?: unknown };
-                        tcpSettings?: { acceptProxyProtocol?: unknown };
-                        wsSettings?: { acceptProxyProtocol?: unknown };
-                        httpupgradeSettings?: { acceptProxyProtocol?: unknown };
-                      };
-                    };
-                    const pick = (v: ProxyWatch) => {
-                      const s = v.streamSettings;
-                      return JSON.stringify([
-                        s?.sockopt?.trustedXForwardedFor,
-                        s?.sockopt?.acceptProxyProtocol,
-                        s?.tcpSettings?.acceptProxyProtocol,
-                        s?.wsSettings?.acceptProxyProtocol,
-                        s?.httpupgradeSettings?.acceptProxyProtocol,
-                      ]);
-                    };
-                    return pick(prev as ProxyWatch) !== pick(curr as ProxyWatch);
-                  }}
-                >
-                  {({ getFieldValue, setFieldValue }) => {
-                    const sockopt = (getFieldValue(['streamSettings', 'sockopt']) ?? {}) as Record<
-                      string,
-                      unknown
-                    >;
-                    const transportField = TRANSPORT_PROXY_FIELD[network];
-                    const transportPP = transportField
-                      ? getFieldValue(['streamSettings', transportField, 'acceptProxyProtocol']) === true
-                      : false;
-                    const proxyOn = sockopt.acceptProxyProtocol === true || transportPP;
-                    const trusted = Array.isArray(sockopt.trustedXForwardedFor)
-                      ? (sockopt.trustedXForwardedFor as string[])
-                      : [];
-                    const value: RealClientIpPreset = proxyOn
-                      ? 'proxy'
-                      : trusted.length > 0
-                        ? 'cloudflare'
-                        : 'off';
-                    const trustedMismatch =
-                      trusted.length > 0 && !TRUSTED_HEADER_NETWORKS.includes(network);
-                    const proxyMismatch = proxyOn && network === 'kcp';
-                    return (
-                      <>
-                        <Form.Item
-                          label={t('pages.inbounds.form.realClientIp')}
-                          tooltip={t('pages.inbounds.form.realClientIpHint')}
-                        >
-                          <Segmented
-                            value={value}
-                            onChange={(v) =>
-                              applyRealClientIpPreset(v as RealClientIpPreset, getFieldValue, setFieldValue)
-                            }
-                            options={[
-                              { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
-                              { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
-                              { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
-                            ]}
-                          />
-                        </Form.Item>
-                        {trustedMismatch && (
-                          <Alert
-                            type="warning"
-                            showIcon
-                            style={{ marginBottom: 16 }}
-                            title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
-                          />
-                        )}
-                        {proxyMismatch && (
-                          <Alert
-                            type="warning"
-                            showIcon
-                            style={{ marginBottom: 16 }}
-                            title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
-                          />
-                        )}
-                      </>
-                    );
-                  }}
-                </Form.Item>
-                <Form.Item name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
-                  label={t('pages.inbounds.form.tcpKeepAliveInterval')}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
-                  label={t('pages.inbounds.form.tcpKeepAliveIdle')}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
-                  label={t('pages.inbounds.form.tcpUserTimeout')}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
-                  label={t('pages.inbounds.form.tcpWindowClamp')}
-                  tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
-                  label={t('pages.inbounds.form.proxyProtocol')}
-                  tooltip={t('pages.inbounds.form.proxyProtocolHint')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpFastOpen']}
-                  label={t('pages.inbounds.form.tcpFastOpen')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'penetrate']}
-                  label={t('pages.inbounds.form.penetrate')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'V6Only']}
-                  label={t('pages.inbounds.form.v6Only')}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'tcpcongestion']}
-                  label={t('pages.inbounds.form.tcpCongestion')}
-                >
-                  <Select
-                    style={{ width: '50%' }}
-                    options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
-                  />
-                </Form.Item>
-                <Form.Item name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
-                  <Select
-                    style={{ width: '50%' }}
-                    options={[
-                      { value: 'off', label: 'Off' },
-                      { value: 'redirect', label: 'Redirect' },
-                      { value: 'tproxy', label: 'TProxy' },
-                    ]}
-                  />
-                </Form.Item>
-                <Form.Item
-                  name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
-                  label={t('pages.inbounds.form.trustedXForwardedFor')}
-                  tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
-                >
-                  <Select
-                    mode="tags"
-                    style={{ width: '100%' }}
-                    tokenSeparators={[',']}
-                    options={[
-                      { value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
-                      { value: 'X-Real-IP', label: 'X-Real-IP' },
-                      { value: 'True-Client-IP', label: 'True-Client-IP' },
-                      { value: 'X-Client-IP', label: 'X-Client-IP' },
-                    ]}
-                  />
-                </Form.Item>
-                <CustomSockoptList />
-              </>
+    <>
+      <Form.Item label="Sockopt">
+        <Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
+      </Form.Item>
+      {on && (
+        <>
+          <Form.Item
+            label={t('pages.inbounds.form.realClientIp')}
+            tooltip={t('pages.inbounds.form.realClientIpHint')}
+          >
+            <Segmented
+              value={presetValue}
+              onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
+              options={[
+                { value: 'off', label: t('pages.inbounds.form.realClientIpPresetOff') },
+                { value: 'cloudflare', label: t('pages.inbounds.form.realClientIpPresetCloudflare') },
+                { value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
+              ]}
+            />
+          </Form.Item>
+          {trustedMismatch && (
+            <Alert
+              type="warning"
+              showIcon
+              style={{ marginBottom: 16 }}
+              title={t('pages.inbounds.form.realClientIpTrustedHeaderTransportWarn')}
+            />
+          )}
+          {proxyMismatch && (
+            <Alert
+              type="warning"
+              showIcon
+              style={{ marginBottom: 16 }}
+              title={t('pages.inbounds.form.realClientIpProxyProtocolTransportWarn')}
+            />
+          )}
+          <FormField name={['streamSettings', 'sockopt', 'mark']} label={t('pages.inbounds.form.routeMark')}>
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
+            label={t('pages.inbounds.form.tcpKeepAliveInterval')}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
+            label={t('pages.inbounds.form.tcpKeepAliveIdle')}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField name={['streamSettings', 'sockopt', 'tcpMaxSeg']} label={t('pages.inbounds.form.tcpMaxSeg')}>
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
+            label={t('pages.inbounds.form.tcpUserTimeout')}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
+            label={t('pages.inbounds.form.tcpWindowClamp')}
+            tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'acceptProxyProtocol']}
+            label={t('pages.inbounds.form.proxyProtocol')}
+            tooltip={t('pages.inbounds.form.proxyProtocolHint')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpFastOpen']}
+            label={t('pages.inbounds.form.tcpFastOpen')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'penetrate']}
+            label={t('pages.inbounds.form.penetrate')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'V6Only']}
+            label={t('pages.inbounds.form.v6Only')}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'tcpcongestion']}
+            label={t('pages.inbounds.form.tcpCongestion')}
+          >
+            <Select
+              style={{ width: '50%' }}
+              options={Object.values(TCP_CONGESTION_OPTION).map((c) => ({ value: c, label: c }))}
+            />
+          </FormField>
+          <FormField name={['streamSettings', 'sockopt', 'tproxy']} label="TProxy">
+            <Select
+              style={{ width: '50%' }}
+              options={[
+                { value: 'off', label: 'Off' },
+                { value: 'redirect', label: 'Redirect' },
+                { value: 'tproxy', label: 'TProxy' },
+              ]}
+            />
+          </FormField>
+          <FormField
+            name={['streamSettings', 'sockopt', 'trustedXForwardedFor']}
+            label={t('pages.inbounds.form.trustedXForwardedFor')}
+            tooltip={t('pages.inbounds.form.trustedXForwardedForHint')}
+          >
+            <Select
+              mode="tags"
+              style={{ width: '100%' }}
+              tokenSeparators={[',']}
+              options={[
+                { value: 'CF-Connecting-IP', label: 'CF-Connecting-IP' },
+                { value: 'X-Real-IP', label: 'X-Real-IP' },
+                { value: 'True-Client-IP', label: 'True-Client-IP' },
+                { value: 'X-Client-IP', label: 'X-Client-IP' },
+              ]}
+            />
+          </FormField>
+          <Controller
+            control={control}
+            name="streamSettings.sockopt.customSockopt"
+            render={({ field }) => (
+              <SockoptCustomField value={field.value} onChange={field.onChange} />
             )}
-          </>
-        );
-      }}
-    </Form.Item>
+          />
+        </>
+      )}
+    </>
   );
 }

+ 13 - 12
frontend/src/pages/inbounds/form/transport/ws.tsx

@@ -1,37 +1,38 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Switch } from 'antd';
+import { Input, InputNumber, Switch } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function WsForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         name={['streamSettings', 'wsSettings', 'acceptProxyProtocol']}
         label={t('pages.inbounds.form.proxyProtocol')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
+      </FormField>
+      <FormField name={['streamSettings', 'wsSettings', 'host']} label={t('host')}>
         <Input />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
+      </FormField>
+      <FormField name={['streamSettings', 'wsSettings', 'path']} label={t('path')}>
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
         label={t('pages.inbounds.form.heartbeatPeriod')}
       >
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.headers')}
         name={['streamSettings', 'wsSettings', 'headers']}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 127 - 119
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -1,39 +1,53 @@
 import { useTranslation } from 'react-i18next';
-import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
-import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import { FormField } from '@/components/form/rhf';
 import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
 
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
-export default function XhttpForm({ form }: { form: FormInstance<InboundFormValues> }) {
+function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
+  return async (value: unknown): Promise<true | string> => {
+    try {
+      await fn(undefined, value);
+      return true;
+    } catch (e) {
+      return (e as Error).message;
+    }
+  };
+}
+
+export default function XhttpForm() {
   const { t } = useTranslation();
-  const xhttpMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'mode'], form);
-  const xhttpObfsMode = Form.useWatch(['streamSettings', 'xhttpSettings', 'xPaddingObfsMode'], form) ?? false;
-  const xhttpSessionIDPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDPlacement'], form);
-  const xhttpSessionIDTable = Form.useWatch(['streamSettings', 'xhttpSettings', 'sessionIDTable'], form);
-  const xhttpSeqPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'seqPlacement'], form);
-  const xhttpUplinkPlacement = Form.useWatch(['streamSettings', 'xhttpSettings', 'uplinkDataPlacement'], form);
+  const { control, getValues, setValue } = useFormContext();
+  const xhttpMode = useWatch({ control, name: 'streamSettings.xhttpSettings.mode' }) as string | undefined;
+  const xhttpObfsMode = !!useWatch({ control, name: 'streamSettings.xhttpSettings.xPaddingObfsMode' });
+  const xhttpSessionIDPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDPlacement' }) as string | undefined;
+  const xhttpSessionIDTable = useWatch({ control, name: 'streamSettings.xhttpSettings.sessionIDTable' });
+  const xhttpSeqPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.seqPlacement' }) as string | undefined;
+  const xhttpUplinkPlacement = useWatch({ control, name: 'streamSettings.xhttpSettings.uplinkDataPlacement' }) as string | undefined;
+  const enableXmux = !!useWatch({ control, name: 'streamSettings.xhttpSettings.enableXmux' });
 
   function onXmuxToggle(checked: boolean) {
     if (!checked) return;
-    const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+    const existing = getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
-    form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+    setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
   }
 
   return (
     <>
-      <Form.Item name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
+      <FormField name={['streamSettings', 'xhttpSettings', 'host']} label={t('host')}>
         <Input />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
+      </FormField>
+      <FormField name={['streamSettings', 'xhttpSettings', 'path']} label={t('path')}>
         <Input />
-      </Form.Item>
-      <Form.Item name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
+      </FormField>
+      <FormField name={['streamSettings', 'xhttpSettings', 'mode']} label={t('pages.inbounds.info.mode')}>
         <Select
           style={{ width: '50%' }}
           options={(['auto', 'packet-up', 'stream-up', 'stream-one'] as const).map((m) => ({
@@ -41,64 +55,64 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
             label: m,
           }))}
         />
-      </Form.Item>
+      </FormField>
       {(xhttpMode === 'packet-up' || xhttpMode === 'auto') && (
         <>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
             label={t('pages.inbounds.form.maxUploadSize')}
           >
             <Input />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
             label={t('pages.inbounds.form.maxBufferedUpload')}
           >
             <InputNumber />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
             label={t('pages.xray.outboundForm.minUploadInterval')}
           >
             <Input placeholder="e.g. 50-150" />
-          </Form.Item>
+          </FormField>
         </>
       )}
       {xhttpMode === 'stream-up' && (
         <>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'scMaxBufferedPosts']}
             label={t('pages.inbounds.form.maxBufferedUpload')}
           >
             <InputNumber />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'scStreamUpServerSecs']}
             label={t('pages.inbounds.form.streamUpServer')}
           >
             <Input />
-          </Form.Item>
+          </FormField>
         </>
       )}
-      <Form.Item
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'serverMaxHeaderBytes']}
         label={t('pages.inbounds.form.serverMaxHeaderBytes')}
       >
         <InputNumber min={0} placeholder="0 (default)" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
         label={t('pages.inbounds.form.paddingBytes')}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'headers']}
         label={t('pages.inbounds.form.headers')}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
         label={t('pages.inbounds.form.uplinkHttpMethod')}
       >
@@ -114,29 +128,29 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
             },
           ]}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
         label={t('pages.inbounds.form.paddingObfsMode')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
       {xhttpObfsMode && (
         <>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
             label={t('pages.inbounds.form.paddingKey')}
           >
             <Input placeholder="x_padding" />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
             label={t('pages.inbounds.form.paddingHeader')}
           >
             <Input placeholder="X-Padding" />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
             label={t('pages.inbounds.form.paddingPlacement')}
           >
@@ -149,8 +163,8 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
                 { value: 'query', label: 'query' },
               ]}
             />
-          </Form.Item>
-          <Form.Item
+          </FormField>
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
             label={t('pages.inbounds.form.paddingMethod')}
           >
@@ -161,10 +175,10 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
                 { value: 'tokenish', label: 'tokenish' },
               ]}
             />
-          </Form.Item>
+          </FormField>
         </>
       )}
-      <Form.Item
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
         label={t('pages.inbounds.form.sessionPlacement')}
       >
@@ -177,38 +191,38 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
             { value: 'query', label: 'query' },
           ]}
         />
-      </Form.Item>
+      </FormField>
       {xhttpSessionIDPlacement && xhttpSessionIDPlacement !== 'path' && (
-        <Form.Item
+        <FormField
           name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
           label={t('pages.inbounds.form.sessionKey')}
         >
           <Input placeholder="x_session" />
-        </Form.Item>
+        </FormField>
       )}
-      <Form.Item
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
         label={t('pages.inbounds.form.sessionIDTable')}
         tooltip={t('pages.inbounds.form.sessionIDTableHint')}
-        rules={[{ validator: validateSessionIDTable }]}
+        rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
       >
         <AutoComplete
           allowClear
           options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
           placeholder="Base62"
         />
-      </Form.Item>
-      {xhttpSessionIDTable && (
-        <Form.Item
+      </FormField>
+      {!!xhttpSessionIDTable && (
+        <FormField
           name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
           label={t('pages.inbounds.form.sessionIDLength')}
           tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
-          rules={[{ validator: validateSessionIDLength }]}
+          rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
         >
           <Input placeholder="8-16" />
-        </Form.Item>
+        </FormField>
       )}
-      <Form.Item
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
         label={t('pages.inbounds.form.sequencePlacement')}
       >
@@ -221,18 +235,18 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
             { value: 'query', label: 'query' },
           ]}
         />
-      </Form.Item>
+      </FormField>
       {xhttpSeqPlacement && xhttpSeqPlacement !== 'path' && (
-        <Form.Item
+        <FormField
           name={['streamSettings', 'xhttpSettings', 'seqKey']}
           label={t('pages.inbounds.form.sequenceKey')}
         >
           <Input placeholder="x_seq" />
-        </Form.Item>
+        </FormField>
       )}
       {xhttpMode === 'packet-up' && (
         <>
-          <Form.Item
+          <FormField
             name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
             label={t('pages.inbounds.form.uplinkDataPlacement')}
           >
@@ -245,83 +259,77 @@ export default function XhttpForm({ form }: { form: FormInstance<InboundFormValu
                 { value: 'query', label: 'query' },
               ]}
             />
-          </Form.Item>
+          </FormField>
           {xhttpUplinkPlacement && xhttpUplinkPlacement !== 'body' && (
-            <Form.Item
+            <FormField
               name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
               label={t('pages.inbounds.form.uplinkDataKey')}
             >
               <Input placeholder="x_data" />
-            </Form.Item>
+            </FormField>
           )}
         </>
       )}
-      <Form.Item
+      <FormField
         name={['streamSettings', 'xhttpSettings', 'noSSEHeader']}
         label={t('pages.inbounds.form.noSseHeader')}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
       {/* XMUX is the connection-multiplexing layer
           xHTTP uses to fan out parallel requests over
           a small pool of upstream connections. UI-only
           toggle (enableXmux) hides the 6 nested knobs
           when off. */}
-      <Form.Item
+      <FormField
         label="XMUX"
         name={['streamSettings', 'xhttpSettings', 'enableXmux']}
-        valuePropName="checked"
+        valueProp="checked"
+        onAfterChange={(v) => onXmuxToggle(v as boolean)}
       >
-        <Switch onChange={onXmuxToggle} />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          if (!form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'enableXmux',
-          ])) return null;
-          return (
-            <>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxConcurrency')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
-              >
-                <Input placeholder="16-32" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxConnections')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
-              >
-                <Input placeholder="0" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxReuseTimes')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
-              >
-                <Input />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxRequestTimes')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
-              >
-                <Input placeholder="600-900" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxReusableSecs')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
-              >
-                <Input placeholder="1800-3000" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.keepAlivePeriod')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
-              >
-                <InputNumber min={0} style={{ width: '100%' }} />
-              </Form.Item>
-            </>
-          );
-        }}
-      </Form.Item>
+        <Switch />
+      </FormField>
+      {enableXmux && (
+        <>
+          <FormField
+            label={t('pages.xray.outboundForm.maxConcurrency')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+          >
+            <Input placeholder="16-32" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxConnections')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+          >
+            <Input placeholder="0" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxReuseTimes')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxRequestTimes')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
+          >
+            <Input placeholder="600-900" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxReusableSecs')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
+          >
+            <Input placeholder="1800-3000" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.keepAlivePeriod')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
+          >
+            <InputNumber min={0} style={{ width: '100%' }} />
+          </FormField>
+        </>
+      )}
     </>
   );
 }

+ 77 - 63
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -1,6 +1,6 @@
 import type { Dispatch, SetStateAction } from 'react';
 import { useTranslation } from 'react-i18next';
-import type { FormInstance } from 'antd';
+import type { UseFormReturn } from 'react-hook-form';
 import type { MessageInstance } from 'antd/es/message/interface';
 
 import { HttpUtil, RandomUtil } from '@/utils';
@@ -10,23 +10,29 @@ import type { InboundFormValues } from '@/schemas/forms/inbound-form';
 import type { RealityScanResult } from '@/generated/types';
 
 interface UseSecurityActionsArgs {
-  form: FormInstance<InboundFormValues>;
+  methods: UseFormReturn<InboundFormValues>;
   setSaving: Dispatch<SetStateAction<boolean>>;
   messageApi: MessageInstance;
-  // Node the inbound is deployed to (null = central panel). "Set Cert from
-  // Panel" must read the node's own cert paths for a node-assigned inbound —
-  // the central panel's paths don't exist on the node. See issue #4854.
+  /*
+   * Node the inbound is deployed to (null = central panel). "Set Cert from
+   * Panel" must read the node's own cert paths for a node-assigned inbound —
+   * the central panel's paths don't exist on the node. See issue #4854.
+   */
   nodeId: number | null;
   setScanResult: Dispatch<SetStateAction<RealityScanResult | null>>;
   setScanning: Dispatch<SetStateAction<boolean>>;
 }
 
-// Server-side TLS / Reality key + certificate generation handlers for the
-// inbound modal's security tab. Each talks to a /panel server endpoint and
-// writes the result back into the form. Lifted out of InboundFormModal so
-// the modal body stays focused on orchestration.
-export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
+/*
+ * Server-side TLS / Reality key + certificate generation handlers for the
+ * inbound modal's security tab. Each talks to a /panel server endpoint and
+ * writes the result back into the form. Lifted out of InboundFormModal so
+ * the modal body stays focused on orchestration.
+ */
+export function useSecurityActions({ methods, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) {
   const { t } = useTranslation();
+  const setValue = methods.setValue as unknown as (name: string, value: unknown) => void;
+  const getValues = methods.getValues as unknown as (name?: string) => unknown;
 
   const genRealityKeypair = async () => {
     setSaving(true);
@@ -34,8 +40,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
       const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
       if (msg?.success) {
         const obj = msg.obj as { privateKey: string; publicKey: string };
-        form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
-        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+        setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
+        setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
       }
     } finally {
       setSaving(false);
@@ -43,8 +49,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   };
 
   const clearRealityKeypair = () => {
-    form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], '');
-    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], '');
+    setValue('streamSettings.realitySettings.privateKey', '');
+    setValue('streamSettings.realitySettings.settings.publicKey', '');
   };
 
   const genMldsa65 = async () => {
@@ -53,8 +59,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
       const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
       if (msg?.success) {
         const obj = msg.obj as { seed: string; verify: string };
-        form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], obj.seed);
-        form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], obj.verify);
+        setValue('streamSettings.realitySettings.mldsa65Seed', obj.seed);
+        setValue('streamSettings.realitySettings.settings.mldsa65Verify', obj.verify);
       }
     } finally {
       setSaving(false);
@@ -62,20 +68,20 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   };
 
   const clearMldsa65 = () => {
-    form.setFieldValue(['streamSettings', 'realitySettings', 'mldsa65Seed'], '');
-    form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], '');
+    setValue('streamSettings.realitySettings.mldsa65Seed', '');
+    setValue('streamSettings.realitySettings.settings.mldsa65Verify', '');
   };
 
   const applyRealityScanResult = (r: RealityScanResult) => {
     setScanResult(r);
-    form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target);
+    setValue('streamSettings.realitySettings.target', r.target);
     if (r.serverNames?.length) {
-      form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames);
+      setValue('streamSettings.realitySettings.serverNames', r.serverNames);
     }
   };
 
   const scanRealityTarget = async () => {
-    const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim();
+    const target = ((getValues('streamSettings.realitySettings.target') as string | undefined) ?? '').trim();
     if (!target) {
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       return;
@@ -118,28 +124,28 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   };
 
   const randomizeShortIds = () => {
-    form.setFieldValue(
-      ['streamSettings', 'realitySettings', 'shortIds'],
+    setValue(
+      'streamSettings.realitySettings.shortIds',
       RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean),
     );
   };
 
   const randomizeSpiderX = () => {
-    form.setFieldValue(
-      ['streamSettings', 'realitySettings', 'settings', 'spiderX'],
+    setValue(
+      'streamSettings.realitySettings.settings.spiderX',
       `/${RandomUtil.randomSeq(15)}`,
     );
   };
 
   const getNewEchCert = async () => {
-    const sni = form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']);
+    const sni = getValues('streamSettings.tlsSettings.serverName');
     setSaving(true);
     try {
       const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni });
       if (msg?.success) {
         const obj = msg.obj as { echServerKeys: string; echConfigList: string };
-        form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], obj.echServerKeys);
-        form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], obj.echConfigList);
+        setValue('streamSettings.tlsSettings.echServerKeys', obj.echServerKeys);
+        setValue('streamSettings.tlsSettings.settings.echConfigList', obj.echConfigList);
       }
     } finally {
       setSaving(false);
@@ -147,15 +153,17 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   };
 
   const clearEchCert = () => {
-    form.setFieldValue(['streamSettings', 'tlsSettings', 'echServerKeys'], '');
-    form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
+    setValue('streamSettings.tlsSettings.echServerKeys', '');
+    setValue('streamSettings.tlsSettings.settings.echConfigList', '');
   };
 
-  // Fill the pinned-cert field from the inbound's own certificate: read the
-  // first configured cert (file path or inline content) and ask the server for
-  // its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
+  /*
+   * Fill the pinned-cert field from the inbound's own certificate: read the
+   * first configured cert (file path or inline content) and ask the server for
+   * its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
+   */
   const pinFromCert = async () => {
-    const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{
+    const certs = (getValues('streamSettings.tlsSettings.certificates') ?? []) as Array<{
       certificateFile?: string;
       certificate?: string[];
     }>;
@@ -175,29 +183,33 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
       }
       const hashes = (msg.obj as string[] | undefined) ?? [];
       if (hashes.length === 0) return;
-      const current = (form.getFieldValue(
-        ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+      const current = (getValues(
+        'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
       ) as string[] | undefined) ?? [];
       const merged = Array.from(new Set([...current, ...hashes]));
-      form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
+      setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
     } finally {
       setSaving(false);
     }
   };
 
-  // Fill the pinned-cert field by pinging the configured SNI: fetches the live
-  // remote certificate hash via `xray tls ping`. Useful when the panel doesn't
-  // hold the cert file (a CDN front / external endpoint).
+  /*
+   * Fill the pinned-cert field by pinging the configured SNI: fetches the live
+   * remote certificate hash via `xray tls ping`. Useful when the panel doesn't
+   * hold the cert file (a CDN front / external endpoint).
+   */
   const pinFromRemote = async () => {
-    const server = ((form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']) as string | undefined) ?? '').trim();
+    const server = ((getValues('streamSettings.tlsSettings.serverName') as string | undefined) ?? '').trim();
     if (!server) {
       messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
       return;
     }
-    // `xray tls ping` defaults to :443, but a self-hosted inbound rarely
-    // listens there. Append the inbound's own port (unless the SNI already
-    // carries one) so the ping reaches the actual TLS endpoint.
-    const port = form.getFieldValue('port') as number | undefined;
+    /*
+     * `xray tls ping` defaults to :443, but a self-hosted inbound rarely
+     * listens there. Append the inbound's own port (unless the SNI already
+     * carries one) so the ping reaches the actual TLS endpoint.
+     */
+    const port = getValues('port') as number | undefined;
     const target = /:\d+$/.test(server) || !port ? server : `${server}:${port}`;
     setSaving(true);
     try {
@@ -208,11 +220,11 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
       }
       const hashes = (msg.obj as string[] | undefined) ?? [];
       if (hashes.length === 0) return;
-      const current = (form.getFieldValue(
-        ['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
+      const current = (getValues(
+        'streamSettings.tlsSettings.settings.pinnedPeerCertSha256',
       ) as string[] | undefined) ?? [];
       const merged = Array.from(new Set([...current, ...hashes]));
-      form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
+      setValue('streamSettings.tlsSettings.settings.pinnedPeerCertSha256', merged);
     } finally {
       setSaving(false);
     }
@@ -221,8 +233,10 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   const setCertFromPanel = async (certName: number) => {
     setSaving(true);
     try {
-      // Node-assigned inbounds run on the node, so their cert files must be the
-      // node's own paths (fetched through the central panel), not this panel's.
+      /*
+       * Node-assigned inbounds run on the node, so their cert files must be the
+       * node's own paths (fetched through the central panel), not this panel's.
+       */
       const msg = typeof nodeId === 'number'
         ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
         : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
@@ -235,12 +249,12 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
         messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
         return;
       }
-      form.setFieldValue(
-        ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+      setValue(
+        `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
         obj.webCertFile ?? '',
       );
-      form.setFieldValue(
-        ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+      setValue(
+        `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
         obj.webKeyFile ?? '',
       );
     } finally {
@@ -249,19 +263,19 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
   };
 
   const clearCertFiles = (certName: number) => {
-    form.setFieldValue(
-      ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+    setValue(
+      `streamSettings.tlsSettings.certificates.${certName}.certificateFile`,
       '',
     );
-    form.setFieldValue(
-      ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+    setValue(
+      `streamSettings.tlsSettings.certificates.${certName}.keyFile`,
       '',
     );
   };
 
   const onSecurityChange = async (next: string) => {
     setScanResult(null);
-    const current = (form.getFieldValue('streamSettings') as Record<string, unknown>) ?? {};
+    const current = (getValues('streamSettings') as Record<string, unknown>) ?? {};
     const cleaned: Record<string, unknown> = { ...current, security: next };
     delete cleaned.tlsSettings;
     delete cleaned.realitySettings;
@@ -275,18 +289,18 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId, setSca
       reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean);
       cleaned.realitySettings = reality;
     }
-    form.setFieldValue('streamSettings', cleaned);
+    setValue('streamSettings', cleaned);
     if (next === 'reality') {
       randomizeSpiderX();
       try {
         const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
         if (msg?.success) {
           const obj = msg.obj as { privateKey: string; publicKey: string };
-          form.setFieldValue(['streamSettings', 'realitySettings', 'privateKey'], obj.privateKey);
-          form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'publicKey'], obj.publicKey);
+          setValue('streamSettings.realitySettings.privateKey', obj.privateKey);
+          setValue('streamSettings.realitySettings.settings.publicKey', obj.publicKey);
         }
       } catch {
-        // best-effort: leave keypair fields empty if server call fails
+        /* best-effort: leave keypair fields empty if server call fails */
       }
     }
   };

+ 58 - 55
frontend/src/pages/login/LoginPage.tsx

@@ -22,8 +22,9 @@ import {
   UserOutlined,
 } from '@ant-design/icons';
 
+import { FormProvider, useForm } from 'react-hook-form';
 import { HttpUtil, LanguageManager } from '@/utils';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { setMessageInstance } from '@/utils/messageBus';
 import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
@@ -48,6 +49,7 @@ export default function LoginPage() {
   const [submitting, setSubmitting] = useState(false);
   const [twoFactorEnable, setTwoFactorEnable] = useState(false);
   const [headlineIndex, setHeadlineIndex] = useState(0);
+  const methods = useForm<LoginForm>({ defaultValues: { username: '', password: '', twoFactorCode: '' } });
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
 
   const headlineWords = useMemo(
@@ -180,66 +182,67 @@ export default function LoginPage() {
                   <b key={headlineIndex}>{headlineWords[headlineIndex]}</b>
                 </h2>
 
-                <Form
-                  layout="vertical"
-                  className="login-form"
-                  onFinish={onSubmit}
-                  initialValues={{ username: '', password: '', twoFactorCode: '' }}
-                >
-                  <Form.Item
-                    label={t('username')}
-                    name="username"
-                    rules={[antdRule(LoginFormSchema.shape.username, t)]}
+                <FormProvider {...methods}>
+                  <Form
+                    layout="vertical"
+                    className="login-form"
+                    onFinish={methods.handleSubmit(onSubmit)}
                   >
-                    <Input
-                      prefix={<UserOutlined />}
-                      autoComplete="username"
-                      size="large"
-                      placeholder={t('username')}
-                      autoFocus
-                    />
-                  </Form.Item>
-
-                  <Form.Item
-                    label={t('password')}
-                    name="password"
-                    rules={[antdRule(LoginFormSchema.shape.password, t)]}
-                  >
-                    <Input.Password
-                      prefix={<LockOutlined />}
-                      autoComplete="current-password"
-                      size="large"
-                      placeholder={t('password')}
-                    />
-                  </Form.Item>
-
-                  {twoFactorEnable && (
-                    <Form.Item
-                      label={t('twoFactorCode')}
-                      name="twoFactorCode"
-                      rules={[antdRule(TwoFactorCodeSchema, t)]}
+                    <FormField
+                      name="username"
+                      label={t('username')}
+                      rules={{ validate: rhfZodValidate(LoginFormSchema.shape.username) }}
                     >
                       <Input
-                        prefix={<KeyOutlined />}
-                        autoComplete="one-time-code"
+                        prefix={<UserOutlined />}
+                        autoComplete="username"
                         size="large"
-                        placeholder={t('twoFactorCode')}
+                        placeholder={t('username')}
+                        autoFocus
                       />
-                    </Form.Item>
-                  )}
-
-                  <Form.Item className="submit-row">
-                    <Button
-                      type="primary"
-                      htmlType="submit"
-                      loading={submitting}
-                      size="large"
-                      block
+                    </FormField>
+
+                    <FormField
+                      name="password"
+                      label={t('password')}
+                      rules={{ validate: rhfZodValidate(LoginFormSchema.shape.password) }}
                     >
-                      {t('login')}
-                    </Button>
-                  </Form.Item>
-                </Form>
+                      <Input.Password
+                        prefix={<LockOutlined />}
+                        autoComplete="current-password"
+                        size="large"
+                        placeholder={t('password')}
+                      />
+                    </FormField>
+
+                    {twoFactorEnable && (
+                      <FormField
+                        name="twoFactorCode"
+                        label={t('twoFactorCode')}
+                        rules={{ validate: rhfZodValidate(TwoFactorCodeSchema) }}
+                      >
+                        <Input
+                          prefix={<KeyOutlined />}
+                          autoComplete="one-time-code"
+                          size="large"
+                          placeholder={t('twoFactorCode')}
+                        />
+                      </FormField>
+                    )}
+
+                    <Form.Item className="submit-row">
+                      <Button
+                        type="primary"
+                        htmlType="submit"
+                        loading={submitting}
+                        size="large"
+                        block
+                      >
+                        {t('login')}
+                      </Button>
+                    </Form.Item>
+                  </Form>
+                </FormProvider>
               </div>
             )}
           </div>

+ 219 - 231
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -13,11 +13,12 @@ import {
   Switch,
   message,
 } from 'antd';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
 import type { Msg } from '@/utils';
 import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
-import { antdRule } from '@/utils/zodForm';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
 import './NodeFormModal.css';
 
@@ -65,7 +66,7 @@ export default function NodeFormModal({
   onOpenChange,
 }: NodeFormModalProps) {
   const { t } = useTranslation();
-  const [form] = Form.useForm<NodeFormValues>();
+  const methods = useForm<NodeFormValues>({ defaultValues: defaultValues() });
   const [messageApi, messageContextHolder] = message.useMessage();
 
   const [submitting, setSubmitting] = useState(false);
@@ -74,9 +75,9 @@ export default function NodeFormModal({
   const [fetchingInbounds, setFetchingInbounds] = useState(false);
   const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
   const [testResult, setTestResult] = useState<ProbeResult | null>(null);
-  const scheme = Form.useWatch('scheme', form) ?? 'https';
-  const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
-  const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
+  const scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
+  const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
+  const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
   const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
 
   // Outbounds and balancers share one picker (like the panel-outbound selector);
@@ -109,11 +110,10 @@ export default function NodeFormModal({
       }
       : base;
     if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
-    form.resetFields();
-    form.setFieldsValue(next);
+    methods.reset(next);
     setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
     setTestResult(null);
-  }, [open, mode, node, form]);
+  }, [open, mode, node, methods]);
 
   const title = useMemo(
     () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
@@ -141,15 +141,11 @@ export default function NodeFormModal({
   }
 
   async function onTest() {
-    try {
-      await form.validateFields(['address', 'port']);
-    } catch {
-      return;
-    }
+    if (!(await methods.trigger(['address', 'port']))) return;
     setTesting(true);
     setTestResult(null);
     try {
-      const payload = buildPayload(form.getFieldsValue(true));
+      const payload = buildPayload(methods.getValues());
       const msg = await testConnection(payload);
       if (msg?.success && msg.obj) {
         setTestResult(msg.obj);
@@ -162,17 +158,13 @@ export default function NodeFormModal({
   }
 
   async function onFetchPin() {
-    try {
-      await form.validateFields(['address', 'port']);
-    } catch {
-      return;
-    }
+    if (!(await methods.trigger(['address', 'port']))) return;
     setFetchingPin(true);
     try {
-      const payload = buildPayload(form.getFieldsValue(true));
+      const payload = buildPayload(methods.getValues());
       const msg = await fetchFingerprint(payload);
       if (msg?.success && msg.obj) {
-        form.setFieldValue('pinnedCertSha256', msg.obj);
+        methods.setValue('pinnedCertSha256', msg.obj);
         messageApi.success(t('pages.nodes.pinFetched'));
       } else {
         messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
@@ -183,14 +175,10 @@ export default function NodeFormModal({
   }
 
   async function onFetchInbounds() {
-    try {
-      await form.validateFields(['name', 'address', 'port', 'apiToken']);
-    } catch {
-      return;
-    }
+    if (!(await methods.trigger(['name', 'address', 'port', 'apiToken']))) return;
     setFetchingInbounds(true);
     try {
-      const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
+      const msg = await fetchInbounds(buildPayload(methods.getValues()));
       if (msg?.success && Array.isArray(msg.obj)) {
         setInboundOptions(msg.obj);
         messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
@@ -242,229 +230,229 @@ export default function NodeFormModal({
         cancelText={t('cancel')}
         mask={{ closable: false }}
         width="640px"
-        onOk={() => form.submit()}
+        onOk={methods.handleSubmit(onFinish)}
         onCancel={close}
       >
-        <Form
-          form={form}
-          layout="vertical"
-          initialValues={defaultValues()}
-          onFinish={onFinish}
-        >
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item
-                label={t('pages.nodes.name')}
-                name="name"
-                rules={[antdRule(NodeFormSchema.shape.name, t)]}
-              >
-                <Input placeholder={t('pages.nodes.namePlaceholder')} />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.nodes.remark')} name="remark">
-                <Input />
-              </Form.Item>
-            </Col>
-          </Row>
+        <FormProvider {...methods}>
+          <Form layout="vertical">
+            <Row gutter={16}>
+              <Col xs={24} md={12}>
+                <FormField
+                  label={t('pages.nodes.name')}
+                  name="name"
+                  rules={{ validate: rhfZodValidate(NodeFormSchema.shape.name) }}
+                >
+                  <Input placeholder={t('pages.nodes.namePlaceholder')} />
+                </FormField>
+              </Col>
+              <Col xs={24} md={12}>
+                <FormField label={t('pages.nodes.remark')} name="remark">
+                  <Input />
+                </FormField>
+              </Col>
+            </Row>
 
-          <Row gutter={16}>
-            <Col xs={24} md={6}>
-              <Form.Item label={t('pages.nodes.scheme')} name="scheme">
-                <Select
-                  options={[
-                    { value: 'https', label: 'https' },
-                    { value: 'http', label: 'http' },
-                  ]}
-                  onChange={(value) => {
-                    if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
+            <Row gutter={16}>
+              <Col xs={24} md={6}>
+                <FormField
+                  label={t('pages.nodes.scheme')}
+                  name="scheme"
+                  onAfterChange={(value) => {
+                    if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
                   }}
-                />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item
-                label={t('pages.nodes.address')}
-                name="address"
-                rules={[antdRule(NodeFormSchema.shape.address, t)]}
-              >
-                <Input placeholder={t('pages.nodes.addressPlaceholder')} />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={6}>
-              <Form.Item
-                label={t('pages.nodes.port')}
-                name="port"
-                rules={[antdRule(NodeFormSchema.shape.port, t)]}
-              >
-                <InputNumber min={1} max={65535} style={{ width: '100%' }} />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Row gutter={16}>
-            <Col xs={24} md={12}>
-              <Form.Item label={t('pages.nodes.basePath')} name="basePath">
-                <Input placeholder="/" />
-              </Form.Item>
-            </Col>
-            <Col xs={24} md={12}>
-              <Form.Item
-                label={t('pages.nodes.enable')}
-                name="enable"
-                valuePropName="checked"
-              >
-                <Switch />
-              </Form.Item>
-            </Col>
-          </Row>
-
-          <Form.Item
-            label={t('pages.nodes.allowPrivateAddress')}
-            name="allowPrivateAddress"
-            valuePropName="checked"
-            tooltip={t('pages.nodes.allowPrivateAddressHint')}
-          >
-            <Switch />
-          </Form.Item>
-
-          <Form.Item
-            label={t('pages.nodes.tlsVerifyMode')}
-            name="tlsVerifyMode"
-            tooltip={t('pages.nodes.tlsVerifyModeHint')}
-          >
-            <Select
-              disabled={scheme === 'http'}
-              options={[
-                { value: 'verify', label: t('pages.nodes.tlsVerify') },
-                { value: 'pin', label: t('pages.nodes.tlsPin') },
-                { value: 'skip', label: t('pages.nodes.tlsSkip') },
-                { value: 'mtls', label: t('pages.nodes.tlsMtls') },
-              ]}
-            />
-          </Form.Item>
+                >
+                  <Select
+                    options={[
+                      { value: 'https', label: 'https' },
+                      { value: 'http', label: 'http' },
+                    ]}
+                  />
+                </FormField>
+              </Col>
+              <Col xs={24} md={12}>
+                <FormField
+                  label={t('pages.nodes.address')}
+                  name="address"
+                  rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
+                >
+                  <Input placeholder={t('pages.nodes.addressPlaceholder')} />
+                </FormField>
+              </Col>
+              <Col xs={24} md={6}>
+                <FormField
+                  label={t('pages.nodes.port')}
+                  name="port"
+                  rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
+                >
+                  <InputNumber min={1} max={65535} style={{ width: '100%' }} />
+                </FormField>
+              </Col>
+            </Row>
 
-          {tlsVerifyMode === 'skip' && (
-            <Alert
-              type="warning"
-              showIcon
-              style={{ marginBottom: 16 }}
-              title={t('pages.nodes.tlsSkipWarning')}
-            />
-          )}
+            <Row gutter={16}>
+              <Col xs={24} md={12}>
+                <FormField label={t('pages.nodes.basePath')} name="basePath">
+                  <Input placeholder="/" />
+                </FormField>
+              </Col>
+              <Col xs={24} md={12}>
+                <FormField
+                  label={t('pages.nodes.enable')}
+                  name="enable"
+                  valueProp="checked"
+                >
+                  <Switch />
+                </FormField>
+              </Col>
+            </Row>
 
-          {tlsVerifyMode === 'mtls' && (
-            <Alert
-              type="info"
-              showIcon
-              style={{ marginBottom: 16 }}
-              title={t('pages.nodes.mtlsFormHint')}
-            />
-          )}
+            <FormField
+              label={t('pages.nodes.allowPrivateAddress')}
+              name="allowPrivateAddress"
+              valueProp="checked"
+              tooltip={t('pages.nodes.allowPrivateAddressHint')}
+            >
+              <Switch />
+            </FormField>
 
-          {tlsVerifyMode === 'pin' && (
-            <Form.Item
-              label={t('pages.nodes.pinnedCert')}
-              name="pinnedCertSha256"
-              tooltip={t('pages.nodes.pinnedCertHint')}
+            <FormField
+              label={t('pages.nodes.tlsVerifyMode')}
+              name="tlsVerifyMode"
+              tooltip={t('pages.nodes.tlsVerifyModeHint')}
             >
-              <Input.Search
-                placeholder={t('pages.nodes.pinnedCertPlaceholder')}
-                enterButton={t('pages.nodes.fetchPin')}
-                loading={fetchingPin}
-                onSearch={onFetchPin}
+              <Select
+                disabled={scheme === 'http'}
+                options={[
+                  { value: 'verify', label: t('pages.nodes.tlsVerify') },
+                  { value: 'pin', label: t('pages.nodes.tlsPin') },
+                  { value: 'skip', label: t('pages.nodes.tlsSkip') },
+                  { value: 'mtls', label: t('pages.nodes.tlsMtls') },
+                ]}
               />
-            </Form.Item>
-          )}
+            </FormField>
 
-          <Form.Item
-            label={t('pages.nodes.apiToken')}
-            name="apiToken"
-            rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
-            tooltip={t('pages.nodes.apiTokenHint')}
-          >
-            <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
-          </Form.Item>
+            {tlsVerifyMode === 'skip' && (
+              <Alert
+                type="warning"
+                showIcon
+                style={{ marginBottom: 16 }}
+                title={t('pages.nodes.tlsSkipWarning')}
+              />
+            )}
+
+            {tlsVerifyMode === 'mtls' && (
+              <Alert
+                type="info"
+                showIcon
+                style={{ marginBottom: 16 }}
+                title={t('pages.nodes.mtlsFormHint')}
+              />
+            )}
 
-          <Form.Item
-            label={t('pages.nodes.outboundTag')}
-            name="outboundTag"
-            tooltip={t('pages.nodes.outboundTagHint')}
-            getValueProps={(v) => ({ value: (v as string) || undefined })}
-          >
-            <Select
-              allowClear
-              showSearch
-              placeholder={t('pages.nodes.outboundTagPlaceholder')}
-              options={outboundOptions}
-            />
-          </Form.Item>
+            {tlsVerifyMode === 'pin' && (
+              <FormField
+                label={t('pages.nodes.pinnedCert')}
+                name="pinnedCertSha256"
+                tooltip={t('pages.nodes.pinnedCertHint')}
+              >
+                <Input.Search
+                  placeholder={t('pages.nodes.pinnedCertPlaceholder')}
+                  enterButton={t('pages.nodes.fetchPin')}
+                  loading={fetchingPin}
+                  onSearch={onFetchPin}
+                />
+              </FormField>
+            )}
 
-          <Form.Item
-            label={t('pages.nodes.inboundSyncMode')}
-            name="inboundSyncMode"
-            tooltip={t('pages.nodes.inboundSyncModeHint')}
-          >
-            <Select
-              options={[
-                { value: 'all', label: t('pages.nodes.allInbounds') },
-                { value: 'selected', label: t('pages.nodes.selectedInbounds') },
-              ]}
-            />
-          </Form.Item>
+            <FormField
+              label={t('pages.nodes.apiToken')}
+              name="apiToken"
+              rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
+              tooltip={t('pages.nodes.apiTokenHint')}
+            >
+              <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
+            </FormField>
 
-          {inboundSyncMode === 'selected' && (
-            <Form.Item
-              label={t('pages.nodes.inboundTags')}
-              name="inboundTags"
-              tooltip={t('pages.nodes.inboundTagsHint')}
+            <FormField
+              label={t('pages.nodes.outboundTag')}
+              name="outboundTag"
+              tooltip={t('pages.nodes.outboundTagHint')}
+              transform={{ input: (v) => (v as string) || undefined }}
             >
               <Select
-                mode="multiple"
                 allowClear
-                loading={fetchingInbounds}
-                placeholder={t('pages.nodes.inboundTagsPlaceholder')}
-                popupRender={(menu) => (
-                  <>
-                    <Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
-                      {t('pages.nodes.loadInbounds')}
-                    </Button>
-                    {menu}
-                  </>
-                )}
-                options={inboundOptions.map((inbound) => ({
-                  value: inbound.tag,
-                  label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
-                }))}
+                showSearch
+                placeholder={t('pages.nodes.outboundTagPlaceholder')}
+                options={outboundOptions}
               />
-            </Form.Item>
-          )}
+            </FormField>
 
-          <div className="test-row">
-            <Button type="default" loading={testing} onClick={onTest}>
-              {t('pages.nodes.testConnection')}
-            </Button>
-            {testResult && (
-              <div className="test-result">
-                {testResult.status === 'online' ? (
-                  <Alert
-                    type="success"
-                    showIcon
-                    title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
-                    description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
-                  />
-                ) : (
-                  <Alert
-                    type="error"
-                    showIcon
-                    title={t('pages.nodes.connectionFailed')}
-                    description={testResult.error}
-                  />
-                )}
-              </div>
+            <FormField
+              label={t('pages.nodes.inboundSyncMode')}
+              name="inboundSyncMode"
+              tooltip={t('pages.nodes.inboundSyncModeHint')}
+            >
+              <Select
+                options={[
+                  { value: 'all', label: t('pages.nodes.allInbounds') },
+                  { value: 'selected', label: t('pages.nodes.selectedInbounds') },
+                ]}
+              />
+            </FormField>
+
+            {inboundSyncMode === 'selected' && (
+              <FormField
+                label={t('pages.nodes.inboundTags')}
+                name="inboundTags"
+                tooltip={t('pages.nodes.inboundTagsHint')}
+              >
+                <Select
+                  mode="multiple"
+                  allowClear
+                  loading={fetchingInbounds}
+                  placeholder={t('pages.nodes.inboundTagsPlaceholder')}
+                  popupRender={(menu) => (
+                    <>
+                      <Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
+                        {t('pages.nodes.loadInbounds')}
+                      </Button>
+                      {menu}
+                    </>
+                  )}
+                  options={inboundOptions.map((inbound) => ({
+                    value: inbound.tag,
+                    label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
+                  }))}
+                />
+              </FormField>
             )}
-          </div>
-        </Form>
+
+            <div className="test-row">
+              <Button type="default" loading={testing} onClick={onTest}>
+                {t('pages.nodes.testConnection')}
+              </Button>
+              {testResult && (
+                <div className="test-result">
+                  {testResult.status === 'online' ? (
+                    <Alert
+                      type="success"
+                      showIcon
+                      title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
+                      description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
+                    />
+                  ) : (
+                    <Alert
+                      type="error"
+                      showIcon
+                      title={t('pages.nodes.connectionFailed')}
+                      description={testResult.error}
+                    />
+                  )}
+                </div>
+              )}
+            </div>
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 166 - 187
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -1,16 +1,18 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
+import type { Path } from 'react-hook-form';
 
 import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
 import {
   BalancerFormSchema,
   type BalancerFormValues,
 } from '@/schemas/xray';
 import {
   BalancerStrategyTypeSchema,
-  type BalancerStrategySettings,
   type BalancerStrategyType,
 } from '@/schemas/routing';
 
@@ -37,15 +39,7 @@ const STRATEGIES = BalancerStrategyTypeSchema.options.map((value) => ({
   label: STRATEGY_LABELS[value] ?? value,
 }));
 
-interface FormState {
-  tag: string;
-  strategy: BalancerStrategyType;
-  selector: string[];
-  fallbackTag: string;
-  settings?: BalancerStrategySettings;
-}
-
-function initialState(balancer: BalancerFormValue | null): FormState {
+function initialState(balancer: BalancerFormValue | null): BalancerFormValues {
   if (!balancer) {
     return { tag: '', strategy: 'random', selector: [], fallbackTag: '' };
   }
@@ -67,64 +61,46 @@ export default function BalancerFormModal({
   onConfirm,
 }: BalancerFormModalProps) {
   const { t } = useTranslation();
-  const [state, setState] = useState<FormState>(() => initialState(balancer));
-  const [touched, setTouched] = useState<Partial<Record<keyof FormState, boolean>>>({});
+  const methods = useForm<BalancerFormValues>({ defaultValues: initialState(balancer) });
   const [submitAttempted, setSubmitAttempted] = useState(false);
   const isEdit = balancer != null;
 
-  const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
-    setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true }));
-    setState((prev) => ({ ...prev, [key]: value }));
-  };
+  useEffect(() => {
+    if (open) {
+      methods.reset(initialState(balancer));
+      setSubmitAttempted(false);
+    }
+  }, [open, balancer, methods]);
 
-  const parsed = useMemo(
-    () => BalancerFormSchema.safeParse(state),
-    [state],
-  );
-  const duplicateTag = !!state.tag.trim() && otherTags.includes(state.tag.trim());
-  const issues = useMemo(() => {
-    const map: Record<string, string> = {};
+  const strategy = useWatch({ control: methods.control, name: 'strategy' });
+  const baselines = useWatch({ control: methods.control, name: 'settings.baselines' }) ?? [];
+  const costs = useWatch({ control: methods.control, name: 'settings.costs' }) ?? [];
+
+  function submit() {
+    const values = methods.getValues();
+    const parsed = BalancerFormSchema.safeParse(values);
+    const trimmedTag = (values.tag ?? '').trim();
+    const duplicateTag = !!trimmedTag && otherTags.includes(trimmedTag);
+    methods.clearErrors();
     if (!parsed.success) {
+      const seen = new Set<string>();
       for (const issue of parsed.error.issues) {
         const key = String(issue.path[0] ?? '');
-        if (!map[key]) map[key] = t(issue.message, { defaultValue: issue.message });
+        if (key && !seen.has(key)) {
+          seen.add(key);
+          methods.setError(key as Path<BalancerFormValues>, { message: issue.message });
+        }
       }
     }
-    return map;
-  }, [parsed, t]);
-
-  const showTagIssue = submitAttempted || !!touched.tag;
-  const showSelectorIssue = submitAttempted || !!touched.selector;
-  const tagError = showTagIssue ? issues.tag : '';
-  const selectorError = showSelectorIssue ? issues.selector : '';
-  const showDuplicate = showTagIssue && duplicateTag;
-
-  function submit() {
     if (!parsed.success || duplicateTag) {
       setSubmitAttempted(true);
       return;
     }
-    const values = { ...parsed.data };
-    if (values.strategy !== 'leastLoad') delete values.settings;
-    onConfirm(values);
+    const result: BalancerFormValues = { ...parsed.data };
+    if (result.strategy !== 'leastLoad') delete result.settings;
+    onConfirm(result);
   }
 
-  const settings = state.settings;
-  const updateSetting = <K extends keyof BalancerStrategySettings>(
-    key: K,
-    value: BalancerStrategySettings[K],
-  ) => {
-    setState((prev) => ({
-      ...prev,
-      settings: { ...(prev.settings ?? {}), [key]: value },
-    }));
-  };
-  const updateBaselines = (next: string[]) => updateSetting('baselines', next);
-  const updateCosts = (next: NonNullable<BalancerStrategySettings['costs']>) => updateSetting('costs', next);
-
-  const baselines = settings?.baselines ?? [];
-  const costs = settings?.costs ?? [];
-
   const fallbackOptions = useMemo(
     () => ['', ...outboundTags].map((tg) => ({ value: tg, label: tg || `(${t('none')})` })),
     [outboundTags, t],
@@ -145,141 +121,144 @@ export default function BalancerFormModal({
       onOk={submit}
       onCancel={onClose}
     >
-      <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item
-          label={t('pages.xray.balancer.tag')}
-          required
-          validateStatus={tagError ? 'error' : showDuplicate ? 'warning' : ''}
-          help={tagError || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
-          hasFeedback
-        >
-          <Input
-            value={state.tag}
-            onChange={(e) => update('tag', e.target.value)}
-            placeholder={t('pages.xray.balancer.tagPlaceholder')}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.balancer.balancerStrategy')}>
-          <Select
-            value={state.strategy}
-            onChange={(v) => update('strategy', v)}
-            options={STRATEGIES}
-          />
-        </Form.Item>
-        <Form.Item
-          label={t('pages.xray.balancer.selector')}
-          required
-          validateStatus={selectorError ? 'error' : ''}
-          help={selectorError || ''}
-          hasFeedback
-        >
-          <Select
-            mode="tags"
-            value={state.selector}
-            onChange={(v) => update('selector', v)}
-            tokenSeparators={[',']}
-            options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.balancer.fallback')}>
-          <Select
-            value={state.fallbackTag}
-            onChange={(v) => update('fallbackTag', v ?? '')}
-            allowClear
-            options={fallbackOptions}
-          />
-        </Form.Item>
-
-        {state.strategy === 'leastLoad' && (
-          <>
-            <Form.Item label={t('pages.xray.balancer.expected')}>
-              <InputNumber
-                value={settings?.expected}
-                onChange={(v) => updateSetting('expected', typeof v === 'number' ? v : undefined)}
-                min={0}
-                placeholder={t('pages.xray.balancer.expectedPlaceholder')}
-                style={{ width: '100%' }}
-              />
-            </Form.Item>
-            <Form.Item label={t('pages.xray.balancer.maxRtt')}>
-              <Input
-                value={settings?.maxRTT ?? ''}
-                onChange={(e) => updateSetting('maxRTT', e.target.value || undefined)}
-                placeholder="e.g. 1s"
-              />
-            </Form.Item>
-            <Form.Item label={t('pages.xray.balancer.tolerance')}>
-              <InputNumber
-                value={settings?.tolerance}
-                onChange={(v) => updateSetting('tolerance', typeof v === 'number' ? v : undefined)}
-                min={0}
-                max={1}
-                step={0.01}
-                placeholder="0.01 = 1%"
-                style={{ width: '100%' }}
-              />
-            </Form.Item>
-            <Form.Item label={t('pages.xray.balancer.baselines')}>
-              <Button
-                size="small"
-                type="primary"
-                icon={<PlusOutlined />}
-                aria-label={t('add')}
-                onClick={() => updateBaselines([...baselines, ''])}
-              />
-              {baselines.map((b, idx) => (
-                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+      <FormProvider {...methods}>
+        <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
+          <Controller
+            control={methods.control}
+            name="tag"
+            render={({ field, fieldState }) => {
+              const trimmed = (field.value ?? '').trim();
+              const duplicate = !!trimmed && otherTags.includes(trimmed);
+              const errorMessage = fieldState.error?.message
+                ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
+                : '';
+              const showDuplicate = !errorMessage && (submitAttempted || fieldState.isTouched) && duplicate;
+              return (
+                <Form.Item
+                  label={t('pages.xray.balancer.tag')}
+                  required
+                  validateStatus={errorMessage ? 'error' : showDuplicate ? 'warning' : ''}
+                  help={errorMessage || (showDuplicate ? t('pages.xray.balancer.tagDuplicate') : '')}
+                  hasFeedback
+                >
                   <Input
-                    value={b}
-                    aria-label={t('pages.xray.balancer.baselines')}
-                    placeholder="e.g. 1s"
-                    onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
+                    value={field.value}
+                    onChange={(e) => field.onChange(e.target.value)}
+                    onBlur={field.onBlur}
+                    ref={field.ref}
+                    placeholder={t('pages.xray.balancer.tagPlaceholder')}
                   />
-                  <InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-            <Form.Item label={t('pages.xray.balancer.costs')}>
-              <Button
-                size="small"
-                type="primary"
-                icon={<PlusOutlined />}
-                aria-label={t('add')}
-                onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
-              />
-              {costs.map((c, idx) => (
-                <Space.Compact key={idx} block style={{ marginTop: 4 }}>
-                  <Switch
-                    checked={c.regexp}
-                    aria-label={t('pages.xray.balancer.costRegexp')}
-                    checkedChildren="re"
-                    unCheckedChildren="lit"
-                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
-                  />
-                  <Input
-                    value={c.match}
-                    aria-label={t('pages.xray.balancer.costMatch')}
-                    placeholder="tag pattern"
-                    onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
-                  />
-                  <InputNumber
-                    value={c.value}
-                    aria-label={t('pages.xray.balancer.costValue')}
-                    placeholder="weight"
-                    style={{ width: 100 }}
-                    onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
-                  />
-                  <InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-          </>
-        )}
-      </Form>
+                </Form.Item>
+              );
+            }}
+          />
+          <FormField name="strategy" label={t('pages.xray.balancer.balancerStrategy')}>
+            <Select options={STRATEGIES} />
+          </FormField>
+          <FormField name="selector" label={t('pages.xray.balancer.selector')} required>
+            <Select
+              mode="tags"
+              tokenSeparators={[',']}
+              options={outboundTags.map((tg) => ({ value: tg, label: tg }))}
+            />
+          </FormField>
+          <FormField
+            name="fallbackTag"
+            label={t('pages.xray.balancer.fallback')}
+            transform={{ output: (v) => v ?? '' }}
+          >
+            <Select allowClear options={fallbackOptions} />
+          </FormField>
+
+          {strategy === 'leastLoad' && (
+            <>
+              <FormField
+                name={['settings', 'expected']}
+                label={t('pages.xray.balancer.expected')}
+                transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
+              >
+                <InputNumber
+                  min={0}
+                  placeholder={t('pages.xray.balancer.expectedPlaceholder')}
+                  style={{ width: '100%' }}
+                />
+              </FormField>
+              <FormField
+                name={['settings', 'maxRTT']}
+                label={t('pages.xray.balancer.maxRtt')}
+                transform={{ input: (v) => v ?? '', output: (v) => (typeof v === 'string' && v ? v : undefined) }}
+              >
+                <Input placeholder="e.g. 1s" />
+              </FormField>
+              <FormField
+                name={['settings', 'tolerance']}
+                label={t('pages.xray.balancer.tolerance')}
+                transform={{ output: (v) => (typeof v === 'number' ? v : undefined) }}
+              >
+                <InputNumber min={0} max={1} step={0.01} placeholder="0.01 = 1%" style={{ width: '100%' }} />
+              </FormField>
+              <Form.Item label={t('pages.xray.balancer.baselines')}>
+                <Button
+                  size="small"
+                  type="primary"
+                  icon={<PlusOutlined />}
+                  aria-label={t('add')}
+                  onClick={() => methods.setValue('settings.baselines', [...baselines, ''])}
+                />
+                {baselines.map((b, idx) => (
+                  <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                    <Input
+                      value={b}
+                      aria-label={t('pages.xray.balancer.baselines')}
+                      placeholder="e.g. 1s"
+                      onChange={(e) => methods.setValue('settings.baselines', baselines.map((x, i) => (i === idx ? e.target.value : x)))}
+                    />
+                    <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.baselines', baselines.filter((_, i) => i !== idx))}>
+                      <MinusOutlined />
+                    </InputAddon>
+                  </Space.Compact>
+                ))}
+              </Form.Item>
+              <Form.Item label={t('pages.xray.balancer.costs')}>
+                <Button
+                  size="small"
+                  type="primary"
+                  icon={<PlusOutlined />}
+                  aria-label={t('add')}
+                  onClick={() => methods.setValue('settings.costs', [...costs, { regexp: false, match: '', value: 1 }])}
+                />
+                {costs.map((c, idx) => (
+                  <Space.Compact key={idx} block style={{ marginTop: 4 }}>
+                    <Switch
+                      checked={c.regexp}
+                      aria-label={t('pages.xray.balancer.costRegexp')}
+                      checkedChildren="re"
+                      unCheckedChildren="lit"
+                      onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
+                    />
+                    <Input
+                      value={c.match}
+                      aria-label={t('pages.xray.balancer.costMatch')}
+                      placeholder="tag pattern"
+                      onChange={(e) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
+                    />
+                    <InputNumber
+                      value={c.value}
+                      aria-label={t('pages.xray.balancer.costValue')}
+                      placeholder="weight"
+                      style={{ width: 100 }}
+                      onChange={(v) => methods.setValue('settings.costs', costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
+                    />
+                    <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('settings.costs', costs.filter((_, i) => i !== idx))}>
+                      <MinusOutlined />
+                    </InputAddon>
+                  </Space.Compact>
+                ))}
+              </Form.Item>
+            </>
+          )}
+        </Form>
+      </FormProvider>
     </Modal>
   );
 }

+ 105 - 118
frontend/src/pages/xray/dns/DnsServerModal.tsx

@@ -2,15 +2,16 @@ import { useEffect } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
 import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { InputAddon } from '@/components/ui';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import {
   DnsQueryStrategySchema,
   DnsServerObjectInnerSchema,
   DnsServerObjectSchema,
   type DnsServerObject,
 } from '@/schemas/dns';
-import { antdRule } from '@/utils/zodForm';
 
 export type DnsServerValue =
   | string
@@ -135,17 +136,15 @@ export default function DnsServerModal({
   onConfirm,
 }: DnsServerModalProps) {
   const { t } = useTranslation();
-  const [form] = Form.useForm<DnsServerForm>();
+  const methods = useForm<DnsServerForm>({ defaultValues: defaultFormValues() });
+  const domains = useWatch({ control: methods.control, name: 'domains' }) ?? [];
+  const expectedIPs = useWatch({ control: methods.control, name: 'expectedIPs' }) ?? [];
+  const unexpectedIPs = useWatch({ control: methods.control, name: 'unexpectedIPs' }) ?? [];
 
   useEffect(() => {
     if (!open) return;
-    form.setFieldsValue(valuesFromServer(server));
-  }, [open, server, form]);
-
-  async function submit() {
-    const values = await form.validateFields();
-    onConfirm(valuesToWire(values));
-  }
+    methods.reset(valuesFromServer(server));
+  }, [open, server, methods]);
 
   const title = isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add');
 
@@ -156,124 +155,112 @@ export default function DnsServerModal({
       okText={t('confirm')}
       cancelText={t('close')}
       mask={{ closable: false }}
-      onOk={submit}
+      onOk={methods.handleSubmit((values) => onConfirm(valuesToWire(values)))}
       onCancel={onClose}
     >
-      <Form
-        form={form}
-        colon={false}
-        labelCol={{ md: { span: 8 } }}
-        wrapperCol={{ md: { span: 14 } }}
-        initialValues={defaultFormValues()}
-      >
-        <Form.Item
-          label={t('pages.inbounds.address')}
-          name="address"
-          rules={[antdRule(shape.address, t)]}
-        >
-          <Input />
-        </Form.Item>
-        <Form.Item
-          label={t('pages.inbounds.port')}
-          name="port"
-          rules={[antdRule(shape.port, t)]}
-        >
-          <InputNumber min={1} max={65535} />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.tag')} name="tag">
-          <Input />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.clientIp')} name="clientIP">
-          <Input />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.strategy')} name="queryStrategy">
-          <Select
-            style={{ width: '100%' }}
-            options={STRATEGIES.map((s) => ({ value: s, label: s }))}
-          />
-        </Form.Item>
-        <Form.Item
-          label={t('pages.xray.dns.timeoutMs')}
-          name="timeoutMs"
-          rules={[antdRule(shape.timeoutMs, t)]}
+      <FormProvider {...methods}>
+        <Form
+          colon={false}
+          labelCol={{ md: { span: 8 } }}
+          wrapperCol={{ md: { span: 14 } }}
         >
-          <InputNumber min={0} step={500} />
-        </Form.Item>
+          <FormField
+            label={t('pages.inbounds.address')}
+            name="address"
+            rules={{ validate: rhfZodValidate(shape.address) }}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.port')}
+            name="port"
+            rules={{ validate: rhfZodValidate(shape.port) }}
+          >
+            <InputNumber min={1} max={65535} />
+          </FormField>
+          <FormField label={t('pages.xray.dns.tag')} name="tag">
+            <Input />
+          </FormField>
+          <FormField label={t('pages.xray.dns.clientIp')} name="clientIP">
+            <Input />
+          </FormField>
+          <FormField label={t('pages.xray.dns.strategy')} name="queryStrategy">
+            <Select
+              style={{ width: '100%' }}
+              options={STRATEGIES.map((s) => ({ value: s, label: s }))}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.xray.dns.timeoutMs')}
+            name="timeoutMs"
+            rules={{ validate: rhfZodValidate(shape.timeoutMs) }}
+          >
+            <InputNumber min={0} step={500} />
+          </FormField>
 
-        <Divider style={{ margin: '5px 0' }} />
+          <Divider style={{ margin: '5px 0' }} />
 
-        <Form.List name="domains">
-          {(fields, { add, remove }) => (
-            <Form.Item label={t('pages.xray.dns.domains')}>
-              <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
-              {fields.map((field) => (
-                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
-                  <Form.Item name={field.name} noStyle>
-                    <Input />
-                  </Form.Item>
-                  <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-          )}
-        </Form.List>
+          <Form.Item label={t('pages.xray.dns.domains')}>
+            <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('domains', [...domains, ''])} />
+            {domains.map((_, i) => (
+              <Space.Compact key={i} block style={{ marginTop: 4 }}>
+                <FormField name={`domains.${i}`} noStyle>
+                  <Input />
+                </FormField>
+                <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('domains', domains.filter((__, idx) => idx !== i))}>
+                  <MinusOutlined />
+                </InputAddon>
+              </Space.Compact>
+            ))}
+          </Form.Item>
 
-        <Form.List name="expectedIPs">
-          {(fields, { add, remove }) => (
-            <Form.Item label={t('pages.xray.dns.expectIPs')}>
-              <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
-              {fields.map((field) => (
-                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
-                  <Form.Item name={field.name} noStyle>
-                    <Input />
-                  </Form.Item>
-                  <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-          )}
-        </Form.List>
+          <Form.Item label={t('pages.xray.dns.expectIPs')}>
+            <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('expectedIPs', [...expectedIPs, ''])} />
+            {expectedIPs.map((_, i) => (
+              <Space.Compact key={i} block style={{ marginTop: 4 }}>
+                <FormField name={`expectedIPs.${i}`} noStyle>
+                  <Input />
+                </FormField>
+                <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('expectedIPs', expectedIPs.filter((__, idx) => idx !== i))}>
+                  <MinusOutlined />
+                </InputAddon>
+              </Space.Compact>
+            ))}
+          </Form.Item>
 
-        <Form.List name="unexpectedIPs">
-          {(fields, { add, remove }) => (
-            <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
-              <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
-              {fields.map((field) => (
-                <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
-                  <Form.Item name={field.name} noStyle>
-                    <Input />
-                  </Form.Item>
-                  <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
-                    <MinusOutlined />
-                  </InputAddon>
-                </Space.Compact>
-              ))}
-            </Form.Item>
-          )}
-        </Form.List>
+          <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
+            <Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => methods.setValue('unexpectedIPs', [...unexpectedIPs, ''])} />
+            {unexpectedIPs.map((_, i) => (
+              <Space.Compact key={i} block style={{ marginTop: 4 }}>
+                <FormField name={`unexpectedIPs.${i}`} noStyle>
+                  <Input />
+                </FormField>
+                <InputAddon ariaLabel={t('remove')} onClick={() => methods.setValue('unexpectedIPs', unexpectedIPs.filter((__, idx) => idx !== i))}>
+                  <MinusOutlined />
+                </InputAddon>
+              </Space.Compact>
+            ))}
+          </Form.Item>
 
-        <Divider style={{ margin: '5px 0' }} />
+          <Divider style={{ margin: '5px 0' }} />
 
-        <Form.Item label={t('pages.xray.dns.skipFallback')} name="skipFallback" valuePropName="checked">
-          <Switch />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.finalQuery')} name="finalQuery" valuePropName="checked">
-          <Switch />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.disableCache')} name="disableCache" valuePropName="checked">
-          <Switch />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveStale')} name="serveStale" valuePropName="checked">
-          <Switch />
-        </Form.Item>
-        <Form.Item label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
-          <InputNumber min={0} step={60} />
-        </Form.Item>
-      </Form>
+          <FormField label={t('pages.xray.dns.skipFallback')} name="skipFallback" valueProp="checked">
+            <Switch />
+          </FormField>
+          <FormField label={t('pages.xray.dns.finalQuery')} name="finalQuery" valueProp="checked">
+            <Switch />
+          </FormField>
+          <FormField label={t('pages.xray.dns.disableCache')} name="disableCache" valueProp="checked">
+            <Switch />
+          </FormField>
+          <FormField label={t('pages.xray.dns.serveStale')} name="serveStale" valueProp="checked">
+            <Switch />
+          </FormField>
+          <FormField label={t('pages.xray.dns.serveExpiredTTL')} name="serveExpiredTTL">
+            <InputNumber min={0} step={60} />
+          </FormField>
+        </Form>
+      </FormProvider>
     </Modal>
   );
 }

+ 294 - 285
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -11,8 +11,9 @@ import {
   Tabs,
   message,
 } from 'antd';
-import { FinalMaskForm } from '@/lib/xray/forms/transport';
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
+import { FinalMaskField, SniffingField } from '@/lib/xray/forms/fields';
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { JsonEditor } from '@/components/form';
 import { Wireguard } from '@/utils';
 import {
@@ -31,7 +32,6 @@ import {
   canEnableTls,
   canEnableTlsFlow,
 } from '@/lib/xray/protocol-capabilities';
-import { antdRule } from '@/utils/zodForm';
 
 import {
   FLOW_OPTIONS,
@@ -75,10 +75,7 @@ import {
 import { RealityForm, TlsForm } from './security';
 import './OutboundFormModal.css';
 
-// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
-// file so the build stays green section-by-section. The atomic swap at
-// the end of the rewrite replaces the legacy file in one commit
-// (per Core Decision 7 in the migration spec).
+type StreamValue = OutboundFormValues['streamSettings'];
 
 interface OutboundFormModalProps {
   open: boolean;
@@ -89,7 +86,6 @@ interface OutboundFormModalProps {
   onConfirm: (outbound: Record<string, unknown>) => void;
 }
 
-
 export default function OutboundFormModal({
   open,
   outbound: outboundProp,
@@ -100,15 +96,36 @@ export default function OutboundFormModal({
 }: OutboundFormModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
-  const [form] = Form.useForm<OutboundFormValues>();
+  const methods = useForm<OutboundFormValues>({ defaultValues: buildAddModeValues() });
   const [activeKey, setActiveKey] = useState('1');
   const [jsonText, setJsonText] = useState('');
   const [jsonDirty, setJsonDirty] = useState(false);
   const [linkInput, setLinkInput] = useState('');
 
-  // Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
-  // hysteria2:// / wireguard://) and replace form state with the result.
-  // The current tag is preserved when the parsed link doesn't carry one.
+  const isEdit = outboundProp != null;
+  const title = isEdit
+    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
+    : `+ ${t('pages.xray.Outbounds')}`;
+  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
+
+  const tag = (useWatch({ control: methods.control, name: 'tag' }) ?? '') as string;
+  const protocol = (useWatch({ control: methods.control, name: 'protocol' }) ?? 'vless') as string;
+  const network = (useWatch({ control: methods.control, name: 'streamSettings.network' }) ?? '') as string;
+  const security = (useWatch({ control: methods.control, name: 'streamSettings.security' }) ?? 'none') as string;
+  const flow = (useWatch({ control: methods.control, name: 'settings.flow' }) ?? '') as string;
+  const reverseTag = useWatch({ control: methods.control, name: 'settings.reverseTag' });
+  const wgSecretKey = useWatch({ control: methods.control, name: 'settings.secretKey' }) as string | undefined;
+
+  const streamAllowed = canEnableStream({ protocol });
+  const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
+  const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
+  const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
+
+  /*
+   * Parse a share link (vmess:// / vless:// / trojan:// / ss:// /
+   * hysteria2:// / wireguard://) and replace form state with the result.
+   * The current tag is preserved when the parsed link doesn't carry one.
+   */
   function importLink() {
     const link = linkInput.trim();
     if (!link) return;
@@ -117,11 +134,10 @@ export default function OutboundFormModal({
       messageApi.error('Wrong Link!');
       return;
     }
-    const currentTag = form.getFieldValue('tag') as string | undefined;
+    const currentTag = methods.getValues('tag');
     if (!parsed.tag && currentTag) parsed.tag = currentTag;
     const next = rawOutboundToFormValues(parsed);
-    form.resetFields();
-    form.setFieldsValue(next);
+    methods.reset(next);
     setJsonText(JSON.stringify(formValuesToWirePayload(next), null, 2));
     setJsonDirty(false);
     setLinkInput('');
@@ -129,89 +145,76 @@ export default function OutboundFormModal({
     switchTab('1');
   }
 
-  const isEdit = outboundProp != null;
-  const title = isEdit
-    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
-    : `+ ${t('pages.xray.Outbounds')}`;
-  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
-
   useEffect(() => {
     if (!open) return;
     const initial = outboundProp
       ? rawOutboundToFormValues(outboundProp)
       : buildAddModeValues();
-    form.resetFields();
-    form.setFieldsValue(initial);
+    methods.reset(initial);
     setActiveKey('1');
     setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
     setJsonDirty(false);
-  }, [open, outboundProp, form]);
-
-  const tag = Form.useWatch('tag', form) ?? '';
-  const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
-  const network = (Form.useWatch(['streamSettings', 'network'], { form, preserve: true }) ?? '') as string;
-  const security = (Form.useWatch(['streamSettings', 'security'], { form, preserve: true }) ?? 'none') as string;
-  const streamAllowed = canEnableStream({ protocol });
-  const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
-  const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } });
-  const tlsFlowAllowed = canEnableTlsFlow({ protocol, streamSettings: { network, security } });
+  }, [open, outboundProp, methods]);
 
   useEffect(() => {
     if (!streamAllowed) return;
-    // Wireguard dials its own UDP — only finalmask/sockopt apply, never a
-    // transport. Don't seed network 'tcp'; clear a leftover one (from a
-    // protocol switch) so the transmission/security blocks stay hidden.
+    /*
+     * Wireguard dials its own UDP — only finalmask/sockopt apply, never a
+     * transport. Don't seed network 'tcp'; clear a leftover one (from a
+     * protocol switch) so the transmission/security blocks stay hidden.
+     */
     if (protocol === 'wireguard') {
-      if (network) form.setFieldValue('streamSettings', { security: 'none' });
+      if (network) methods.setValue('streamSettings', { security: 'none' } as StreamValue);
       return;
     }
     if (network) return;
-    form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [streamAllowed, network, protocol]);
+    methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
+  }, [streamAllowed, network, protocol, methods]);
 
   useEffect(() => {
     if (protocol !== 'hysteria') return;
     if (network === 'hysteria' && security === 'tls') return;
-    const existing = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
+    const existing = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
     const slice = hysteriaStreamSlice();
     if (existing.hysteriaSettings) slice.hysteriaSettings = existing.hysteriaSettings;
     if (existing.tlsSettings) slice.tlsSettings = existing.tlsSettings;
-    form.setFieldValue('streamSettings', slice);
-  }, [protocol, network, security, form]);
+    methods.setValue('streamSettings', slice as StreamValue);
+  }, [protocol, network, security, methods]);
 
-  const wgSecretKey = Form.useWatch(['settings', 'secretKey'], form) as string | undefined;
   useEffect(() => {
     if (protocol !== 'wireguard') return;
     const sk = (wgSecretKey ?? '').trim();
     if (!sk) {
-      form.setFieldValue(['settings', 'pubKey'], '');
+      methods.setValue('settings.pubKey', '');
       return;
     }
     try {
       const { publicKey } = Wireguard.generateKeypair(sk);
-      form.setFieldValue(['settings', 'pubKey'], publicKey);
+      methods.setValue('settings.pubKey', publicKey);
     } catch {
-      form.setFieldValue(['settings', 'pubKey'], '');
+      methods.setValue('settings.pubKey', '');
     }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [protocol, wgSecretKey]);
-
-  function onValuesChange(changed: Partial<OutboundFormValues>) {
-    if ('protocol' in changed && changed.protocol) {
-      const next = rawOutboundToFormValues({ protocol: changed.protocol });
-      form.setFieldValue('settings', next.settings);
-      if (changed.protocol === 'hysteria') {
-        form.setFieldValue('streamSettings', hysteriaStreamSlice());
-      } else if ((form.getFieldValue(['streamSettings', 'network']) ?? '') === 'hysteria') {
-        form.setFieldValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' });
+  }, [protocol, wgSecretKey, methods]);
+
+  useEffect(() => {
+    /* eslint-disable-next-line react-hooks/incompatible-library */
+    const sub = methods.watch((_value, { name, type }) => {
+      if (name !== 'protocol' || type !== 'change') return;
+      const nextProtocol = methods.getValues('protocol');
+      const next = rawOutboundToFormValues({ protocol: nextProtocol });
+      methods.setValue('settings', next.settings);
+      if (nextProtocol === 'hysteria') {
+        methods.setValue('streamSettings', hysteriaStreamSlice() as StreamValue);
+      } else if ((methods.getValues('streamSettings.network') ?? '') === 'hysteria') {
+        methods.setValue('streamSettings', { ...newStreamSlice('tcp'), security: 'none' } as StreamValue);
       }
-    }
-  }
+    });
+    return () => sub.unsubscribe();
+  }, [methods]);
 
   function onSecurityChange(next: string) {
-    const stream = form.getFieldValue('streamSettings') ?? {};
-    const cleaned = { ...stream } as Record<string, unknown>;
+    const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
+    const cleaned = { ...stream };
     delete cleaned.tlsSettings;
     delete cleaned.realitySettings;
     if (next === 'tls') {
@@ -234,23 +237,25 @@ export default function OutboundFormModal({
       };
     }
     cleaned.security = next;
-    form.setFieldValue('streamSettings', cleaned);
+    methods.setValue('streamSettings', cleaned as StreamValue);
   }
 
-  // Network change cascade: swap the per-network sub-key (tcpSettings,
-  // wsSettings, etc.) so the DU branch matches. Preserve security if
-  // the new network supports it, otherwise force back to 'none'.
+  /*
+   * Network change cascade: swap the per-network sub-key (tcpSettings,
+   * wsSettings, etc.) so the DU branch matches. Preserve security if
+   * the new network supports it, otherwise force back to 'none'.
+   */
   function onNetworkChange(next: string) {
-    const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
-    form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
+    const stream = (methods.getValues('streamSettings') ?? {}) as Record<string, unknown>;
+    methods.setValue('streamSettings', applyNetworkChange(protocol, stream, next) as StreamValue);
   }
 
   function onXmuxToggle(checked: boolean) {
     if (!checked) return;
-    const existing = form.getFieldValue(['streamSettings', 'xhttpSettings', 'xmux']);
+    const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
-    form.setFieldValue(['streamSettings', 'xhttpSettings', 'xmux'], { ...XMUX_DEFAULTS });
+    methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
   }
 
   const duplicateTag = useMemo(() => {
@@ -260,9 +265,11 @@ export default function OutboundFormModal({
     return (existingTags || []).includes(myTag);
   }, [tag, existingTags, isEdit, outboundProp]);
 
-  // Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
-  // any edits into form state. When entering JSON tab, snapshot current
-  // form values so the user sees the live shape.
+  /*
+   * Bridge form <-> JSON tab: when leaving the JSON tab back to Basic, push
+   * any edits into form state. When entering JSON tab, snapshot current
+   * form values so the user sees the live shape.
+   */
   function applyJsonToForm(): boolean {
     if (!jsonDirty) return true;
     const raw = jsonText.trim();
@@ -275,8 +282,7 @@ export default function OutboundFormModal({
       return false;
     }
     const next = rawOutboundToFormValues(parsed);
-    form.resetFields();
-    form.setFieldsValue(next);
+    methods.reset(next);
     setJsonDirty(false);
     return true;
   }
@@ -290,7 +296,7 @@ export default function OutboundFormModal({
 
   function onTabChange(key: string) {
     if (key === '2') {
-      const values = form.getFieldsValue(true) as OutboundFormValues;
+      const values = methods.getValues();
       setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
       setJsonDirty(false);
       switchTab(key);
@@ -315,16 +321,11 @@ export default function OutboundFormModal({
         return;
       }
       values = rawOutboundToFormValues(parsed);
-      form.resetFields();
-      form.setFieldsValue(values);
+      methods.reset(values);
       setJsonDirty(false);
     } else {
-      try {
-        await form.validateFields();
-      } catch {
-        return;
-      }
-      values = form.getFieldsValue(true) as OutboundFormValues;
+      if (!(await methods.trigger())) return;
+      values = methods.getValues();
     }
     const tagValue = (values.tag ?? '').trim();
     if (!tagValue) {
@@ -354,218 +355,226 @@ export default function OutboundFormModal({
         onCancel={onClose}
         destroyOnHidden
       >
-        <Form
-          form={form}
-          colon={false}
-          labelCol={{ md: { span: 8 } }}
-          wrapperCol={{ md: { span: 14 } }}
-          labelWrap
-          onValuesChange={onValuesChange}
-        >
-          <Tabs
-            activeKey={activeKey}
-            onChange={onTabChange}
-            items={[
-              {
-                key: '1',
-                label: t('pages.xray.basicTemplate'),
-                children: (
-                  <>
-                    <Form.Item
-                      label={t('protocol')}
-                      name="protocol"
-                      rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
-                    >
-                      <Select options={PROTOCOL_OPTIONS} />
-                    </Form.Item>
-
-                    <Form.Item
-                      label={t('pages.xray.outbound.tag')}
-                      name="tag"
-                      validateStatus={duplicateTag ? 'warning' : undefined}
-                      help={duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined}
-                      rules={[
-                        { required: true, message: t('pages.xray.outboundForm.tagRequired') },
-                      ]}
-                    >
-                      <Input placeholder={t('pages.xray.outboundForm.tagPlaceholder')} />
-                    </Form.Item>
-
-                    <Form.Item label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
-                      <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
-                    </Form.Item>
-
-                    <Form.Item
-                      label={t('pages.xray.outbound.targetStrategy')}
-                      name="targetStrategy"
-                      tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
-                    >
-                      <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
-                    </Form.Item>
-
-                    {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
-                    {protocol === 'vmess' && <VmessFields />}
-                    {protocol === 'vless' && <VlessFields />}
-                    {protocol === 'trojan' && <TrojanFields />}
-                    {protocol === 'shadowsocks' && <ShadowsocksFields />}
-                    {protocol === 'http' && <HttpFields />}
-                    {protocol === 'socks' && <SocksFields />}
-
-                    {protocol === 'loopback' && <LoopbackFields />}
-                    {protocol === 'blackhole' && <BlackholeFields />}
-                    {protocol === 'dns' && <DnsFields />}
-
-                    {protocol === 'freedom' && <FreedomFields form={form} />}
-
-                    {protocol === 'vless' && (
-                      <Form.Item shouldUpdate noStyle>
-                        {() => {
-                          const reverseTag = form.getFieldValue(['settings', 'reverseTag']);
-                          if (!reverseTag) return null;
+        <FormProvider {...methods}>
+          <Form
+            colon={false}
+            labelCol={{ md: { span: 8 } }}
+            wrapperCol={{ md: { span: 14 } }}
+            labelWrap
+          >
+            <Tabs
+              activeKey={activeKey}
+              onChange={onTabChange}
+              items={[
+                {
+                  key: '1',
+                  label: t('pages.xray.basicTemplate'),
+                  children: (
+                    <>
+                      <FormField
+                        label={t('protocol')}
+                        name="protocol"
+                        rules={{ validate: rhfZodValidate(OutboundFormBaseSchema.shape.tag) }}
+                      >
+                        <Select id="protocol" options={PROTOCOL_OPTIONS} />
+                      </FormField>
+
+                      <Controller
+                        control={methods.control}
+                        name="tag"
+                        rules={{ required: 'pages.xray.outboundForm.tagRequired' }}
+                        render={({ field, fieldState }) => {
+                          const errorMessage = fieldState.error?.message
+                            ? t(fieldState.error.message, { defaultValue: fieldState.error.message })
+                            : '';
                           return (
-                            <SniffingFields
-                              name={['settings', 'reverseSniffing']}
-                              form={form}
-                              enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
-                            />
+                            <Form.Item
+                              label={t('pages.xray.outbound.tag')}
+                              required
+                              validateStatus={errorMessage ? 'error' : duplicateTag ? 'warning' : undefined}
+                              help={errorMessage || (duplicateTag ? t('pages.xray.outboundForm.tagDuplicate') : undefined)}
+                            >
+                              <Input
+                                value={field.value}
+                                onChange={(e) => field.onChange(e.target.value)}
+                                onBlur={field.onBlur}
+                                ref={field.ref}
+                                placeholder={t('pages.xray.outboundForm.tagPlaceholder')}
+                              />
+                            </Form.Item>
                           );
                         }}
-                      </Form.Item>
-                    )}
-
-                    {protocol === 'wireguard' && <WireguardFields form={form} />}
-
-                    {streamAllowed && network && (
-                      <>
-                        <Form.Item
-                          label={t('transmission')}
-                          name={['streamSettings', 'network']}
-                        >
-                          <Select
-                            value={network}
-                            onChange={onNetworkChange}
-                            options={
-                              protocol === 'hysteria'
-                                ? [HYSTERIA_NETWORK_OPTION]
-                                : NETWORK_OPTIONS
-                            }
-                          />
-                        </Form.Item>
+                      />
+
+                      <FormField label={t('pages.xray.outbound.sendThrough')} name="sendThrough">
+                        <Input placeholder={t('pages.xray.outboundForm.localIpPlaceholder')} />
+                      </FormField>
+
+                      <FormField
+                        label={t('pages.xray.outbound.targetStrategy')}
+                        name="targetStrategy"
+                        tooltip={t('pages.xray.outboundForm.targetStrategyHint')}
+                      >
+                        <Select allowClear placeholder="AsIs" options={TARGET_STRATEGY_OPTIONS} />
+                      </FormField>
+
+                      {SERVER_PROTOCOLS.has(protocol) && <ServerTarget />}
+                      {protocol === 'vmess' && <VmessFields />}
+                      {protocol === 'vless' && <VlessFields />}
+                      {protocol === 'trojan' && <TrojanFields />}
+                      {protocol === 'shadowsocks' && <ShadowsocksFields />}
+                      {protocol === 'http' && <HttpFields />}
+                      {protocol === 'socks' && <SocksFields />}
+
+                      {protocol === 'loopback' && <LoopbackFields />}
+                      {protocol === 'blackhole' && <BlackholeFields />}
+                      {protocol === 'dns' && <DnsFields />}
+
+                      {protocol === 'freedom' && <FreedomFields />}
+
+                      {protocol === 'vless' && reverseTag && (
+                        <Controller
+                          control={methods.control}
+                          name="settings.reverseSniffing"
+                          render={({ field }) => (
+                            <SniffingField
+                              value={field.value}
+                              onChange={field.onChange}
+                              enableLabel={t('pages.xray.outboundForm.reverseSniffing')}
+                            />
+                          )}
+                        />
+                      )}
+
+                      {protocol === 'wireguard' && <WireguardFields />}
+
+                      {streamAllowed && network && (
+                        <>
+                          <Form.Item label={t('transmission')}>
+                            <Select
+                              value={network}
+                              onChange={onNetworkChange}
+                              options={
+                                protocol === 'hysteria'
+                                  ? [HYSTERIA_NETWORK_OPTION]
+                                  : NETWORK_OPTIONS
+                              }
+                            />
+                          </Form.Item>
 
-                        {network === 'tcp' && <RawForm form={form} />}
+                          {network === 'tcp' && <RawForm />}
 
-                        {network === 'kcp' && <KcpForm />}
+                          {network === 'kcp' && <KcpForm />}
 
-                        {network === 'ws' && <WsForm />}
+                          {network === 'ws' && <WsForm />}
 
-                        {network === 'grpc' && <GrpcForm />}
+                          {network === 'grpc' && <GrpcForm />}
 
-                        {network === 'httpupgrade' && <HttpUpgradeForm />}
+                          {network === 'httpupgrade' && <HttpUpgradeForm />}
 
-                        {network === 'xhttp' && <XhttpForm form={form} onXmuxToggle={onXmuxToggle} />}
+                          {network === 'xhttp' && <XhttpForm onXmuxToggle={onXmuxToggle} />}
 
-                        {network === 'hysteria' && <HysteriaForm form={form} />}
-                      </>
-                    )}
+                          {network === 'hysteria' && <HysteriaForm />}
+                        </>
+                      )}
 
-                    {tlsFlowAllowed && (
-                      <Form.Item label={t('pages.clients.flow')} name={['settings', 'flow']}>
-                        <Select
-                          allowClear
-                          placeholder={t('none')}
-                          options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
-                        />
-                      </Form.Item>
-                    )}
-
-                    {/* Vision seed knobs only meaningful for the exact
-                        xtls-rprx-vision flow, on TCP+(tls|reality). The
-                        legacy class gated this on `canEnableVisionSeed()`
-                        — same condition encoded inline here. */}
-                    <Form.Item shouldUpdate noStyle>
-                      {() => {
-                        const flow =
-                          (form.getFieldValue(['settings', 'flow']) ?? '') as string;
-                        if (!(tlsFlowAllowed && flow === 'xtls-rprx-vision')) return null;
-                        return (
-                          <>
-                            <Form.Item label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
-                              <InputNumber min={0} style={{ width: '100%' }} />
-                            </Form.Item>
-                            <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
-                              <Space.Compact block>
-                                {[900, 500, 900, 256].map((def, i) => (
-                                  <Form.Item key={i} name={['settings', 'testseed', i]} noStyle initialValue={def}>
-                                    <InputNumber min={1} style={{ width: '25%' }} />
-                                  </Form.Item>
-                                ))}
-                              </Space.Compact>
-                            </Form.Item>
-                          </>
-                        );
-                      }}
-                    </Form.Item>
-
-                    {streamAllowed && network && (
-                      <Form.Item label={t('security')}>
-                        <Radio.Group
-                          value={security}
-                          buttonStyle="solid"
-                          onChange={(e) => onSecurityChange(e.target.value as string)}
-                        >
-                          {network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
-                          {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
-                          {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
-                        </Radio.Group>
-                      </Form.Item>
-                    )}
-
-                    {security === 'tls' && tlsAllowed && <TlsForm />}
-
-                    {security === 'reality' && realityAllowed && <RealityForm />}
-
-                    {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
-                      <SockoptForm form={form} outboundTags={dialerProxyTags ?? existingTags} />
-                    )}
-
-                    <FinalMaskForm
-                      name={['streamSettings', 'finalmask']}
-                      network={network}
-                      protocol={protocol}
-                      form={form}
-                    />
-
-                    <MuxForm form={form} protocol={protocol} network={network} />
-                  </>
-                ),
-              },
-              {
-                key: '2',
-                label: 'JSON',
-                children: (
-                  <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
-                    <Input.Search
-                      value={linkInput}
-                      placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
-                      enterButton="Import"
-                      onChange={(e) => setLinkInput(e.target.value)}
-                      onSearch={importLink}
-                    />
-                    <JsonEditor
-                      value={jsonText}
-                      onChange={(next) => {
-                        setJsonText(next);
-                        setJsonDirty(true);
-                      }}
-                      minHeight="360px"
-                      maxHeight="600px"
-                    />
-                  </Space>
-                ),
-              },
-            ]}
-          />
-        </Form>
+                      {tlsFlowAllowed && (
+                        <FormField label={t('pages.clients.flow')} name={['settings', 'flow']}>
+                          <Select
+                            allowClear
+                            placeholder={t('none')}
+                            options={[{ value: '', label: t('none') }, ...FLOW_OPTIONS]}
+                          />
+                        </FormField>
+                      )}
+
+                      {/* Vision seed knobs only meaningful for the exact
+                          xtls-rprx-vision flow, on TCP+(tls|reality). */}
+                      {tlsFlowAllowed && flow === 'xtls-rprx-vision' && (
+                        <>
+                          <FormField label={t('pages.xray.outboundForm.visionTestpre')} name={['settings', 'testpre']}>
+                            <InputNumber min={0} style={{ width: '100%' }} />
+                          </FormField>
+                          <Form.Item label={t('pages.inbounds.form.visionTestseed')}>
+                            <Space.Compact block>
+                              {[0, 1, 2, 3].map((i) => (
+                                <FormField key={i} name={['settings', 'testseed', i]} noStyle>
+                                  <InputNumber min={1} style={{ width: '25%' }} />
+                                </FormField>
+                              ))}
+                            </Space.Compact>
+                          </Form.Item>
+                        </>
+                      )}
+
+                      {streamAllowed && network && (
+                        <Form.Item label={t('security')}>
+                          <Radio.Group
+                            value={security}
+                            buttonStyle="solid"
+                            onChange={(e) => onSecurityChange(e.target.value as string)}
+                          >
+                            {network !== 'hysteria' && <Radio.Button value="none">{t('none')}</Radio.Button>}
+                            {tlsAllowed && <Radio.Button value="tls">TLS</Radio.Button>}
+                            {realityAllowed && <Radio.Button value="reality">Reality</Radio.Button>}
+                          </Radio.Group>
+                        </Form.Item>
+                      )}
+
+                      {security === 'tls' && tlsAllowed && <TlsForm />}
+
+                      {security === 'reality' && realityAllowed && <RealityForm />}
+
+                      {((streamAllowed && network) || !streamAllowed || protocol === 'wireguard') && (
+                        <SockoptForm outboundTags={dialerProxyTags ?? existingTags} />
+                      )}
+
+                      <Controller
+                        control={methods.control}
+                        name="streamSettings.finalmask"
+                        render={({ field }) => (
+                          <FinalMaskField
+                            key={`${protocol}:${network}`}
+                            value={field.value}
+                            onChange={field.onChange}
+                            network={network}
+                            protocol={protocol}
+                          />
+                        )}
+                      />
+
+                      <MuxForm protocol={protocol} network={network} />
+                    </>
+                  ),
+                },
+                {
+                  key: '2',
+                  label: 'JSON',
+                  children: (
+                    <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
+                      <Input.Search
+                        value={linkInput}
+                        placeholder="vmess:// vless:// trojan:// ss:// hysteria2:// wireguard://"
+                        enterButton="Import"
+                        onChange={(e) => setLinkInput(e.target.value)}
+                        onSearch={importLink}
+                      />
+                      <JsonEditor
+                        value={jsonText}
+                        onChange={(next) => {
+                          setJsonText(next);
+                          setJsonDirty(true);
+                        }}
+                        minHeight="360px"
+                        maxHeight="600px"
+                      />
+                    </Space>
+                  ),
+                },
+              ]}
+            />
+          </Form>
+        </FormProvider>
       </Modal>
     </>
   );

+ 5 - 3
frontend/src/pages/xray/outbounds/protocols/blackhole.tsx

@@ -1,10 +1,12 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Select } from 'antd';
+import { Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function BlackholeFields() {
   const { t } = useTranslation();
   return (
-    <Form.Item label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
+    <FormField label={t('pages.xray.outboundForm.responseType')} name={['settings', 'type']}>
       <Select
         options={[
           { value: '', label: '(empty)' },
@@ -12,6 +14,6 @@ export default function BlackholeFields() {
           { value: 'http', label: 'http' },
         ]}
       />
-    </Form.Item>
+    </FormField>
   );
 }

+ 50 - 52
frontend/src/pages/xray/outbounds/protocols/dns.tsx

@@ -1,15 +1,19 @@
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, InputNumber, Select } from 'antd';
 import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
 
+import { FormField } from '@/components/form/rhf';
 import { activateOnKey } from '@/utils/a11y';
 import { DNSRuleActions } from '@/schemas/primitives';
 
 export default function DnsFields() {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({ control, name: 'settings.rules' });
   return (
     <>
-      <Form.Item label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
+      <FormField label={t('pages.xray.outboundForm.rewriteNetwork')} name={['settings', 'rewriteNetwork']}>
         <Select
           allowClear
           placeholder={t('pages.xray.outboundForm.unchanged')}
@@ -18,62 +22,56 @@ export default function DnsFields() {
             { value: 'tcp', label: 'tcp' },
           ]}
         />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
+      </FormField>
+      <FormField label={t('pages.inbounds.form.rewriteAddress')} name={['settings', 'rewriteAddress']}>
         <Input placeholder={t('pages.xray.outboundForm.unchangedAddress')} />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
+      </FormField>
+      <FormField label={t('pages.inbounds.form.rewritePort')} name={['settings', 'rewritePort']}>
         <InputNumber min={0} max={65535} style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
+      </FormField>
+      <FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
         <InputNumber min={0} style={{ width: '100%' }} />
+      </FormField>
+      <Form.Item label={t('pages.xray.outboundForm.rules')}>
+        <Button
+          size="small"
+          type="primary"
+          icon={<PlusOutlined />}
+          aria-label={t('add')}
+          onClick={() => append({ action: 'direct', qType: '', domain: '', rCode: 0 })}
+        />
       </Form.Item>
-      <Form.List name={['settings', 'rules']}>
-        {(fields, { add, remove }) => (
-          <>
-            <Form.Item label={t('pages.xray.outboundForm.rules')}>
-              <Button
-                size="small"
-                type="primary"
-                icon={<PlusOutlined />}
-                aria-label={t('add')}
-                onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
+      {fields.map((field, index) => (
+        <div key={field.id}>
+          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <div className="item-heading">
+              <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
+              <DeleteOutlined
+                className="danger-icon"
+                role="button"
+                tabIndex={0}
+                aria-label={t('remove')}
+                onClick={() => remove(index)}
+                onKeyDown={activateOnKey(() => remove(index))}
               />
-            </Form.Item>
-            {fields.map((field, index) => (
-              <div key={field.key}>
-                <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-                  <div className="item-heading">
-                    <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
-                    <DeleteOutlined
-                      className="danger-icon"
-                      role="button"
-                      tabIndex={0}
-                      aria-label={t('remove')}
-                      onClick={() => remove(field.name)}
-                      onKeyDown={activateOnKey(() => remove(field.name))}
-                    />
-                  </div>
-                </Form.Item>
-                <Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
-                  <Select
-                    options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
-                  />
-                </Form.Item>
-                <Form.Item label="QType" name={[field.name, 'qType']}>
-                  <Input placeholder="1,3,23-24" />
-                </Form.Item>
-                <Form.Item label={t('domainName')} name={[field.name, 'domain']}>
-                  <Input placeholder="domain:example.com" />
-                </Form.Item>
-                <Form.Item label="RCode" name={[field.name, 'rCode']}>
-                  <InputNumber min={0} max={65535} style={{ width: '100%' }} />
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
+            </div>
+          </Form.Item>
+          <FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'rules', index, 'action']}>
+            <Select
+              options={DNSRuleActions.map((a) => ({ value: a, label: a }))}
+            />
+          </FormField>
+          <FormField label="QType" name={['settings', 'rules', index, 'qType']}>
+            <Input placeholder="1,3,23-24" />
+          </FormField>
+          <FormField label={t('domainName')} name={['settings', 'rules', index, 'domain']}>
+            <Input placeholder="domain:example.com" />
+          </FormField>
+          <FormField label="RCode" name={['settings', 'rules', index, 'rCode']}>
+            <InputNumber min={0} max={65535} style={{ width: '100%' }} />
+          </FormField>
+        </div>
+      ))}
     </>
   );
 }

+ 213 - 257
frontend/src/pages/xray/outbounds/protocols/freedom.tsx

@@ -1,30 +1,56 @@
 import { useTranslation } from 'react-i18next';
-import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch } from 'antd';
 import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
 
+import { FormField } from '@/components/form/rhf';
 import { activateOnKey } from '@/utils/a11y';
 import { OutboundDomainStrategies } from '@/schemas/primitives';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 
-export default function FreedomFields({ form }: { form: FormInstance<OutboundFormValues> }) {
+interface FragmentValue {
+  packets?: string;
+  length?: string;
+  interval?: string;
+  maxSplit?: string;
+}
+
+export default function FreedomFields() {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+
+  const fragment = (useWatch({ control, name: 'settings.fragment' }) ?? {}) as FragmentValue;
+  const fragmentEnabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
+
+  const {
+    fields: noiseFields,
+    append: appendNoise,
+    remove: removeNoise,
+  } = useFieldArray({ control, name: 'settings.noises' });
+
+  const {
+    fields: finalRuleFields,
+    append: appendFinalRule,
+    remove: removeFinalRule,
+  } = useFieldArray({ control, name: 'settings.finalRules' });
+  const finalRulesValues = (useWatch({ control, name: 'settings.finalRules' }) ?? []) as { action?: string }[];
+
   return (
     <>
-      <Form.Item label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
+      <FormField label={t('pages.xray.balancer.balancerStrategy')} name={['settings', 'domainStrategy']}>
         <Select
           options={[
             { value: '', label: `(${t('none')})` },
             ...OutboundDomainStrategies.map((s) => ({ value: s, label: s })),
           ]}
         />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
+      </FormField>
+      <FormField label={t('pages.xray.outboundForm.redirect')} name={['settings', 'redirect']}>
         <Input />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
+      </FormField>
+      <FormField label={t('pages.xray.tun.userLevel')} name={['settings', 'userLevel']}>
         <InputNumber min={0} style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
+      </FormField>
+      <FormField label={t('pages.xray.outboundForm.proxyProtocol')} name={['settings', 'proxyProtocol']}>
         <Select
           options={[
             { value: 0, label: `(${t('none')})` },
@@ -32,260 +58,190 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
             { value: 2, label: 'v2' },
           ]}
         />
-      </Form.Item>
+      </FormField>
 
-      <Form.Item label={t('pages.xray.outboundForm.fragment')} shouldUpdate noStyle>
-        {() => {
-          const fragment = (form.getFieldValue(['settings', 'fragment']) ?? {}) as {
-            packets?: string;
-            length?: string;
-            interval?: string;
-            maxSplit?: string;
-          };
-          const enabled = !!(fragment.length || fragment.interval || fragment.maxSplit);
-          return (
-            <>
-              <Form.Item label="Fragment">
-                <Switch
-                  checked={enabled}
-                  onChange={(checked) => {
-                    form.setFieldValue(
-                      ['settings', 'fragment'],
-                      checked
-                        ? {
-                          packets: 'tlshello',
-                          length: '100-200',
-                          interval: '10-20',
-                          maxSplit: '300-400',
-                        }
-                        : { packets: '', length: '', interval: '', maxSplit: '' },
-                    );
-                  }}
-                />
-              </Form.Item>
-              {enabled && (
-                <>
-                  <Form.Item
-                    label={t('pages.settings.subFormats.packets')}
-                    name={['settings', 'fragment', 'packets']}
-                    rules={[{
-                      validator: (_rule, value) => {
-                        const str = String(value ?? '').trim();
-                        // xray accepts "tlshello" or any packet-number range (#5075)
-                        if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) {
-                          return Promise.resolve();
-                        }
-                        return Promise.reject(new Error('Use "tlshello" or a packet range like 1-3'));
-                      },
-                    }]}
-                  >
-                    <AutoComplete
-                      options={[
-                        { value: 'tlshello', label: 'tlshello' },
-                        { value: '1-3', label: '1-3' },
-                        { value: '1-5', label: '1-5' },
-                      ]}
-                      placeholder="tlshello or n-m, e.g. 1-3"
-                    />
-                  </Form.Item>
-                  <Form.Item label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.settings.subFormats.interval')}
-                    name={['settings', 'fragment', 'interval']}
-                  >
-                    <Input />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.settings.subFormats.maxSplit')}
-                    name={['settings', 'fragment', 'maxSplit']}
-                  >
-                    <Input />
-                  </Form.Item>
-                </>
-              )}
-            </>
-          );
-        }}
+      <Form.Item label="Fragment">
+        <Switch
+          checked={fragmentEnabled}
+          onChange={(checked) => {
+            setValue(
+              'settings.fragment',
+              checked
+                ? {
+                  packets: 'tlshello',
+                  length: '100-200',
+                  interval: '10-20',
+                  maxSplit: '300-400',
+                }
+                : { packets: '', length: '', interval: '', maxSplit: '' },
+            );
+          }}
+        />
       </Form.Item>
+      {fragmentEnabled && (
+        <>
+          <FormField
+            label={t('pages.settings.subFormats.packets')}
+            name={['settings', 'fragment', 'packets']}
+            rules={{
+              validate: (value) => {
+                const str = String(value ?? '').trim();
+                /* xray accepts "tlshello" or any packet-number range (#5075) */
+                if (str === '' || str === 'tlshello' || /^\d+-\d+$/.test(str)) return true;
+                return 'Use "tlshello" or a packet range like 1-3';
+              },
+            }}
+          >
+            <AutoComplete
+              options={[
+                { value: 'tlshello', label: 'tlshello' },
+                { value: '1-3', label: '1-3' },
+                { value: '1-5', label: '1-5' },
+              ]}
+              placeholder="tlshello or n-m, e.g. 1-3"
+            />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.length')} name={['settings', 'fragment', 'length']}>
+            <Input />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.interval')} name={['settings', 'fragment', 'interval']}>
+            <Input />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.maxSplit')} name={['settings', 'fragment', 'maxSplit']}>
+            <Input />
+          </FormField>
+        </>
+      )}
 
-      <Form.List name={['settings', 'noises']}>
-        {(fields, { add, remove }) => (
-          <>
-            <Form.Item label={t('pages.settings.subFormats.noises')}>
-              <Switch
-                checked={fields.length > 0}
-                onChange={(checked) => {
-                  if (checked) {
-                    add({
-                      type: 'rand',
-                      packet: '10-20',
-                      delay: '10-16',
-                      applyTo: 'ip',
-                    });
-                  } else {
-                    // remove() with no arg is not supported;
-                    // walk fields in reverse and drop each.
-                    for (let i = fields.length - 1; i >= 0; i--) {
-                      remove(fields[i].name);
-                    }
-                  }
-                }}
-              />
-              {fields.length > 0 && (
-                <Button
-                  size="small"
-                  type="primary"
-                  className="ml-8"
-                  icon={<PlusOutlined />}
-                  aria-label={t('add')}
-                  onClick={() =>
-                    add({
-                      type: 'rand',
-                      packet: '10-20',
-                      delay: '10-16',
-                      applyTo: 'ip',
-                    })
-                  }
+      <Form.Item label={t('pages.settings.subFormats.noises')}>
+        <Switch
+          checked={noiseFields.length > 0}
+          onChange={(checked) => {
+            if (checked) {
+              appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' });
+            } else {
+              removeNoise();
+            }
+          }}
+        />
+        {noiseFields.length > 0 && (
+          <Button
+            size="small"
+            type="primary"
+            className="ml-8"
+            icon={<PlusOutlined />}
+            aria-label={t('add')}
+            onClick={() => appendNoise({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })}
+          />
+        )}
+      </Form.Item>
+      {noiseFields.map((field, index) => (
+        <div key={field.id}>
+          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <div className="item-heading">
+              <span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
+              {noiseFields.length > 1 && (
+                <DeleteOutlined
+                  className="danger-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('remove')}
+                  onClick={() => removeNoise(index)}
+                  onKeyDown={activateOnKey(() => removeNoise(index))}
                 />
               )}
-            </Form.Item>
-            {fields.map((field, index) => (
-              <div key={field.key}>
-                <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-                  <div className="item-heading">
-                    <span>{t('pages.settings.subFormats.noiseItem', { n: index + 1 })}</span>
-                    {fields.length > 1 && (
-                      <DeleteOutlined
-                        className="danger-icon"
-                        role="button"
-                        tabIndex={0}
-                        aria-label={t('remove')}
-                        onClick={() => remove(field.name)}
-                        onKeyDown={activateOnKey(() => remove(field.name))}
-                      />
-                    )}
-                  </div>
-                </Form.Item>
-                <Form.Item label={t('pages.settings.subFormats.type')} name={[field.name, 'type']}>
-                  <Select
-                    options={['rand', 'base64', 'str', 'hex'].map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-                <Form.Item label={t('pages.settings.subFormats.packet')} name={[field.name, 'packet']}>
-                  <Input />
-                </Form.Item>
-                <Form.Item label={t('pages.settings.subFormats.delayMs')} name={[field.name, 'delay']}>
-                  <Input />
-                </Form.Item>
-                <Form.Item label={t('pages.settings.subFormats.applyTo')} name={[field.name, 'applyTo']}>
-                  <Select
-                    options={['ip', 'ipv4', 'ipv6'].map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
+            </div>
+          </Form.Item>
+          <FormField label={t('pages.settings.subFormats.type')} name={['settings', 'noises', index, 'type']}>
+            <Select
+              options={['rand', 'base64', 'str', 'hex'].map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.packet')} name={['settings', 'noises', index, 'packet']}>
+            <Input />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.delayMs')} name={['settings', 'noises', index, 'delay']}>
+            <Input />
+          </FormField>
+          <FormField label={t('pages.settings.subFormats.applyTo')} name={['settings', 'noises', index, 'applyTo']}>
+            <Select
+              options={['ip', 'ipv4', 'ipv6'].map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+        </div>
+      ))}
 
-      <Form.List name={['settings', 'finalRules']}>
-        {(fields, { add, remove }) => (
-          <>
-            <Form.Item label={t('pages.xray.outboundForm.finalRules')}>
-              <Button
-                size="small"
-                type="primary"
-                icon={<PlusOutlined />}
-                aria-label={t('add')}
-                onClick={() =>
-                  add({
-                    action: 'allow',
-                    network: '',
-                    port: '',
-                    ip: [],
-                    blockDelay: '',
-                  })
-                }
+      <Form.Item label={t('pages.xray.outboundForm.finalRules')}>
+        <Button
+          size="small"
+          type="primary"
+          icon={<PlusOutlined />}
+          aria-label={t('add')}
+          onClick={() => appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })}
+        />
+        <span className="ml-8" style={{ opacity: 0.6 }}>
+          {t('pages.xray.outboundForm.overrideXrayPrivateIp')}
+        </span>
+      </Form.Item>
+      {finalRuleFields.map((field, index) => (
+        <div key={field.id}>
+          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <div className="item-heading">
+              <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
+              <DeleteOutlined
+                className="danger-icon"
+                role="button"
+                tabIndex={0}
+                aria-label={t('remove')}
+                onClick={() => removeFinalRule(index)}
+                onKeyDown={activateOnKey(() => removeFinalRule(index))}
               />
-              <span className="ml-8" style={{ opacity: 0.6 }}>
-                {t('pages.xray.outboundForm.overrideXrayPrivateIp')}
-              </span>
-            </Form.Item>
-            {fields.map((field, index) => (
-              <div key={field.key}>
-                <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-                  <div className="item-heading">
-                    <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
-                    <DeleteOutlined
-                      className="danger-icon"
-                      role="button"
-                      tabIndex={0}
-                      aria-label={t('remove')}
-                      onClick={() => remove(field.name)}
-                      onKeyDown={activateOnKey(() => remove(field.name))}
-                    />
-                  </div>
-                </Form.Item>
-                <Form.Item label={t('pages.xray.outboundForm.action')} name={[field.name, 'action']}>
-                  <Select
-                    options={['allow', 'block'].map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-                <Form.Item label={t('pages.inbounds.network')} name={[field.name, 'network']}>
-                  <Select
-                    allowClear
-                    placeholder="(any)"
-                    options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-                <Form.Item label={t('pages.inbounds.port')} name={[field.name, 'port']}>
-                  <Input placeholder="e.g. 80,443 or 1000-2000" />
-                </Form.Item>
-                <Form.Item label="IP / CIDR / geoip" name={[field.name, 'ip']}>
-                  <Select
-                    mode="tags"
-                    tokenSeparators={[',', ' ']}
-                    placeholder="e.g. 10.0.0.0/8, geoip:private"
-                  />
-                </Form.Item>
-                <Form.Item shouldUpdate noStyle>
-                  {() => {
-                    const ruleAction = form.getFieldValue([
-                      'settings',
-                      'finalRules',
-                      field.name,
-                      'action',
-                    ]);
-                    if (ruleAction !== 'block') return null;
-                    return (
-                      <Form.Item
-                        label={t('pages.xray.outboundForm.blockDelay')}
-                        name={[field.name, 'blockDelay']}
-                      >
-                        <Input placeholder="optional: 5000-10000" />
-                      </Form.Item>
-                    );
-                  }}
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
+            </div>
+          </Form.Item>
+          <FormField label={t('pages.xray.outboundForm.action')} name={['settings', 'finalRules', index, 'action']}>
+            <Select
+              options={['allow', 'block'].map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+          <FormField label={t('pages.inbounds.network')} name={['settings', 'finalRules', index, 'network']}>
+            <Select
+              allowClear
+              placeholder="(any)"
+              options={['tcp', 'udp', 'tcp,udp'].map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+          <FormField label={t('pages.inbounds.port')} name={['settings', 'finalRules', index, 'port']}>
+            <Input placeholder="e.g. 80,443 or 1000-2000" />
+          </FormField>
+          <FormField label="IP / CIDR / geoip" name={['settings', 'finalRules', index, 'ip']}>
+            <Select
+              mode="tags"
+              tokenSeparators={[',', ' ']}
+              placeholder="e.g. 10.0.0.0/8, geoip:private"
+            />
+          </FormField>
+          {finalRulesValues[index]?.action === 'block' && (
+            <FormField
+              label={t('pages.xray.outboundForm.blockDelay')}
+              name={['settings', 'finalRules', index, 'blockDelay']}
+            >
+              <Input placeholder="optional: 5000-10000" />
+            </FormField>
+          )}
+        </div>
+      ))}
     </>
   );
 }

+ 8 - 7
frontend/src/pages/xray/outbounds/protocols/http.tsx

@@ -1,21 +1,22 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function HttpFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item label={t('username')} name={['settings', 'user']}>
+      <FormField label={t('username')} name={['settings', 'user']}>
         <Input />
-      </Form.Item>
-      <Form.Item label={t('password')} name={['settings', 'pass']}>
+      </FormField>
+      <FormField label={t('password')} name={['settings', 'pass']}>
         <Input />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
+      </FormField>
+      <FormField label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 17 - 9
frontend/src/pages/xray/outbounds/protocols/loopback.tsx

@@ -1,22 +1,30 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+import { Controller, useFormContext } from 'react-hook-form';
 
-import SniffingFields from '@/lib/xray/forms/SniffingFields';
+import { FormField } from '@/components/form/rhf';
+import { SniffingField } from '@/lib/xray/forms/fields';
 
 export default function LoopbackFields() {
   const { t } = useTranslation();
-  const form = Form.useFormInstance();
+  const { control } = useFormContext();
 
   return (
     <>
-      <Form.Item label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
+      <FormField label={t('pages.xray.outboundForm.inboundTag')} name={['settings', 'inboundTag']}>
         <Input placeholder={t('pages.xray.outboundForm.inboundTagPlaceholder')} />
-      </Form.Item>
+      </FormField>
 
-      <SniffingFields
-        name={['settings', 'sniffing']}
-        form={form}
-        enableLabel={t('pages.inbounds.sniffingTab')}
+      <Controller
+        control={control}
+        name="settings.sniffing"
+        render={({ field }) => (
+          <SniffingField
+            value={field.value}
+            onChange={field.onChange}
+            enableLabel={t('pages.inbounds.sniffingTab')}
+          />
+        )}
       />
     </>
   );

+ 11 - 7
frontend/src/pages/xray/outbounds/protocols/server-target.tsx

@@ -1,24 +1,28 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber } from 'antd';
+import { Input, InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function ServerTarget() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.address')}
         name={['settings', 'address']}
-        rules={[{ required: true, message: t('pages.xray.outboundForm.addressRequired') }]}
+        required
+        rules={{ required: 'pages.xray.outboundForm.addressRequired' }}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.port')}
         name={['settings', 'port']}
-        rules={[{ required: true, message: t('pages.xray.outboundForm.portRequired') }]}
+        required
+        rules={{ required: 'pages.xray.outboundForm.portRequired' }}
       >
         <InputNumber min={1} max={65535} style={{ width: '100%' }} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 13 - 13
frontend/src/pages/xray/outbounds/protocols/shadowsocks.tsx

@@ -1,9 +1,9 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Input, InputNumber, Select, Switch } from 'antd';
 
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { ShadowsocksOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
 import { SSMethodSchema } from '@/schemas/protocols/shared/shadowsocks';
-import { antdRule } from '@/utils/zodForm';
 
 import { SS_METHOD_OPTIONS } from '../outbound-form-constants';
 
@@ -11,30 +11,30 @@ export default function ShadowsocksFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('password')}
         name={['settings', 'password']}
-        rules={[antdRule(ShadowsocksOutboundFormSettingsSchema.shape.password, t)]}
+        rules={{ validate: rhfZodValidate(ShadowsocksOutboundFormSettingsSchema.shape.password) }}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('encryption')}
         name={['settings', 'method']}
-        rules={[antdRule(SSMethodSchema, t)]}
+        rules={{ validate: rhfZodValidate(SSMethodSchema) }}
       >
         <Select options={SS_METHOD_OPTIONS} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.xray.outboundForm.udpOverTcp')}
         name={['settings', 'uot']}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
+      </FormField>
+      <FormField label={t('pages.xray.outboundForm.uotVersion')} name={['settings', 'UoTVersion']}>
         <InputNumber min={1} max={2} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 7 - 5
frontend/src/pages/xray/outbounds/protocols/socks.tsx

@@ -1,16 +1,18 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function SocksFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item label={t('username')} name={['settings', 'user']}>
+      <FormField label={t('username')} name={['settings', 'user']}>
         <Input />
-      </Form.Item>
-      <Form.Item label={t('password')} name={['settings', 'pass']}>
+      </FormField>
+      <FormField label={t('password')} name={['settings', 'pass']}>
         <Input />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 5 - 5
frontend/src/pages/xray/outbounds/protocols/trojan.tsx

@@ -1,18 +1,18 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
 
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { TrojanOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
 
 export default function TrojanFields() {
   const { t } = useTranslation();
   return (
-    <Form.Item
+    <FormField
       label={t('password')}
       name={['settings', 'password']}
-      rules={[antdRule(TrojanOutboundFormSettingsSchema.shape.password, t)]}
+      rules={{ validate: rhfZodValidate(TrojanOutboundFormSettingsSchema.shape.password) }}
     >
       <Input />
-    </Form.Item>
+    </FormField>
   );
 }

+ 10 - 10
frontend/src/pages/xray/outbounds/protocols/vless.tsx

@@ -1,33 +1,33 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
 
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import {
   VlessOutboundFormSettingsSchema,
   VmessOutboundFormSettingsSchema,
 } from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
 
 export default function VlessFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label="ID"
         name={['settings', 'id']}
-        rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
+        rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
       >
         <Input placeholder="UUID" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('encryption')}
         name={['settings', 'encryption']}
-        rules={[antdRule(VlessOutboundFormSettingsSchema.shape.encryption, t)]}
+        rules={{ validate: rhfZodValidate(VlessOutboundFormSettingsSchema.shape.encryption) }}
       >
         <Input />
-      </Form.Item>
-      <Form.Item label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
+      </FormField>
+      <FormField label={t('pages.clients.reverseTag')} name={['settings', 'reverseTag']}>
         <Input placeholder={t('pages.xray.outboundForm.optional')} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 8 - 8
frontend/src/pages/xray/outbounds/protocols/vmess.tsx

@@ -1,8 +1,8 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
 
+import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { VmessOutboundFormSettingsSchema } from '@/schemas/forms/outbound-form';
-import { antdRule } from '@/utils/zodForm';
 
 import { SECURITY_OPTIONS } from '../outbound-form-constants';
 
@@ -10,20 +10,20 @@ export default function VmessFields() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label="ID"
         name={['settings', 'id']}
-        rules={[antdRule(VmessOutboundFormSettingsSchema.shape.id, t)]}
+        rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.id) }}
       >
         <Input placeholder="UUID" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('security')}
         name={['settings', 'security']}
-        rules={[antdRule(VmessOutboundFormSettingsSchema.shape.security, t)]}
+        rules={{ validate: rhfZodValidate(VmessOutboundFormSettingsSchema.shape.security) }}
       >
         <Select options={SECURITY_OPTIONS} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 108 - 106
frontend/src/pages/xray/outbounds/protocols/wireguard.tsx

@@ -1,147 +1,149 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInstance } from 'antd';
+import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
 import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
+import { useFieldArray, useFormContext } from 'react-hook-form';
 
 import { Wireguard } from '@/utils';
 import { activateOnKey } from '@/utils/a11y';
 import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
 import { WireguardDomainStrategy } from '@/schemas/primitives';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 
-export default function WireguardFields({ form }: { form: FormInstance<OutboundFormValues> }) {
+function AllowedIPsList({ peerIndex }: { peerIndex: number }) {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const { fields, append, remove } = useFieldArray({
+    control,
+    name: `settings.peers.${peerIndex}.allowedIPs`,
+  });
   return (
     <>
-      <Form.Item label={t('pages.inbounds.address')} name={['settings', 'address']}>
+      {fields.map((field, ipIdx) => (
+        <Space.Compact key={field.id} block style={{ marginBottom: 4 }}>
+          <FormField noStyle name={['settings', 'peers', peerIndex, 'allowedIPs', ipIdx]}>
+            <Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
+          </FormField>
+          {fields.length > 1 && (
+            <InputAddon ariaLabel={t('remove')} onClick={() => remove(ipIdx)}>
+              <MinusOutlined />
+            </InputAddon>
+          )}
+        </Space.Compact>
+      ))}
+      <Button
+        size="small"
+        icon={<PlusOutlined />}
+        aria-label={t('add')}
+        onClick={() => append('')}
+      />
+    </>
+  );
+}
+
+export default function WireguardFields() {
+  const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const {
+    fields: peerFields,
+    append: appendPeer,
+    remove: removePeer,
+  } = useFieldArray({ control, name: 'settings.peers' });
+  return (
+    <>
+      <FormField label={t('pages.inbounds.address')} name={['settings', 'address']}>
         <Input placeholder="comma-separated, e.g. 10.0.0.1,fd00::1" />
-      </Form.Item>
+      </FormField>
       <Form.Item label={t('pages.inbounds.privatekey')}>
         <Space.Compact block>
-          <Form.Item name={['settings', 'secretKey']} noStyle>
+          <FormField name={['settings', 'secretKey']} noStyle>
             <Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
-          </Form.Item>
+          </FormField>
           <Button
             icon={<ReloadOutlined />}
             aria-label={t('regenerate')}
             onClick={() => {
               const pair = Wireguard.generateKeypair();
-              form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
-              form.setFieldValue(['settings', 'pubKey'], pair.publicKey);
+              setValue('settings.secretKey', pair.privateKey);
+              setValue('settings.pubKey', pair.publicKey);
             }}
           />
         </Space.Compact>
       </Form.Item>
-      <Form.Item label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
+      <FormField label={t('pages.inbounds.publicKey')} name={['settings', 'pubKey']}>
         <Input disabled />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
+      </FormField>
+      <FormField label={t('pages.xray.wireguard.domainStrategy')} name={['settings', 'domainStrategy']}>
         <Select
           options={[
             { value: '', label: `(${t('none')})` },
             ...WireguardDomainStrategy.map((s) => ({ value: s, label: s })),
           ]}
         />
-      </Form.Item>
-      <Form.Item label="MTU" name={['settings', 'mtu']}>
+      </FormField>
+      <FormField label="MTU" name={['settings', 'mtu']}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.info.noKernelTun')}
         name={['settings', 'noKernelTun']}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
+      </FormField>
+      <FormField label={t('pages.xray.outboundForm.reserved')} name={['settings', 'reserved']}>
         <Input placeholder="comma-separated bytes, e.g. 1,2,3" />
+      </FormField>
+      <Form.Item label={t('pages.inbounds.form.peers')}>
+        <Button
+          size="small"
+          type="primary"
+          icon={<PlusOutlined />}
+          aria-label={t('add')}
+          onClick={() =>
+            appendPeer({
+              publicKey: '',
+              psk: '',
+              allowedIPs: ['0.0.0.0/0', '::/0'],
+              endpoint: '',
+              keepAlive: 0,
+            })
+          }
+        />
       </Form.Item>
-      <Form.List name={['settings', 'peers']}>
-        {(fields, { add, remove }) => (
-          <>
-            <Form.Item label={t('pages.inbounds.form.peers')}>
-              <Button
-                size="small"
-                type="primary"
-                icon={<PlusOutlined />}
-                aria-label={t('add')}
-                onClick={() =>
-                  add({
-                    publicKey: '',
-                    psk: '',
-                    allowedIPs: ['0.0.0.0/0', '::/0'],
-                    endpoint: '',
-                    keepAlive: 0,
-                  })
-                }
-              />
-            </Form.Item>
-            {fields.map((field, index) => (
-              <div key={field.key}>
-                <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
-                  <div className="item-heading">
-                    <span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
-                    {fields.length > 1 && (
-                      <DeleteOutlined
-                        className="danger-icon"
-                        role="button"
-                        tabIndex={0}
-                        aria-label={t('remove')}
-                        onClick={() => remove(field.name)}
-                        onKeyDown={activateOnKey(() => remove(field.name))}
-                      />
-                    )}
-                  </div>
-                </Form.Item>
-                <Form.Item label={t('pages.xray.wireguard.endpoint')} name={[field.name, 'endpoint']}>
-                  <Input />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.publicKey')}
-                  name={[field.name, 'publicKey']}
-                >
-                  <Input />
-                </Form.Item>
-                <Form.Item label="PSK" name={[field.name, 'psk']}>
-                  <Input />
-                </Form.Item>
-                <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
-                  <Form.List name={[field.name, 'allowedIPs']}>
-                    {(ipFields, { add: addIp, remove: removeIp }) => (
-                      <>
-                        {ipFields.map((ipField, ipIdx) => (
-                          <Space.Compact
-                            key={ipField.key}
-                            block
-                            style={{ marginBottom: 4 }}
-                          >
-                            <Form.Item noStyle name={ipField.name}>
-                              <Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
-                            </Form.Item>
-                            {ipFields.length > 1 && (
-                              <InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
-                                <MinusOutlined />
-                              </InputAddon>
-                            )}
-                          </Space.Compact>
-                        ))}
-                        <Button
-                          size="small"
-                          icon={<PlusOutlined />}
-                          aria-label={t('add')}
-                          onClick={() => addIp('')}
-                        />
-                      </>
-                    )}
-                  </Form.List>
-                </Form.Item>
-                <Form.Item label={t('pages.inbounds.info.keepAlive')} name={[field.name, 'keepAlive']}>
-                  <InputNumber min={0} />
-                </Form.Item>
-              </div>
-            ))}
-          </>
-        )}
-      </Form.List>
+      {peerFields.map((field, index) => (
+        <div key={field.id}>
+          <Form.Item wrapperCol={{ md: { span: 14, offset: 8 } }}>
+            <div className="item-heading">
+              <span>{t('pages.inbounds.info.peerNumber', { n: index + 1 })}</span>
+              {peerFields.length > 1 && (
+                <DeleteOutlined
+                  className="danger-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('remove')}
+                  onClick={() => removePeer(index)}
+                  onKeyDown={activateOnKey(() => removePeer(index))}
+                />
+              )}
+            </div>
+          </Form.Item>
+          <FormField label={t('pages.xray.wireguard.endpoint')} name={['settings', 'peers', index, 'endpoint']}>
+            <Input />
+          </FormField>
+          <FormField label={t('pages.inbounds.publicKey')} name={['settings', 'peers', index, 'publicKey']}>
+            <Input />
+          </FormField>
+          <FormField label="PSK" name={['settings', 'peers', index, 'psk']}>
+            <Input />
+          </FormField>
+          <Form.Item label={t('pages.xray.wireguard.allowedIPs')}>
+            <AllowedIPsList peerIndex={index} />
+          </Form.Item>
+          <FormField label={t('pages.inbounds.info.keepAlive')} name={['settings', 'peers', index, 'keepAlive']}>
+            <InputNumber min={0} />
+          </FormField>
+        </div>
+      ))}
     </>
   );
 }

+ 15 - 13
frontend/src/pages/xray/outbounds/security/reality.tsx

@@ -1,5 +1,7 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 import { UTLS_OPTIONS } from '../outbound-form-constants';
 
@@ -7,42 +9,42 @@ export default function RealityForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label="SNI"
         name={['streamSettings', 'realitySettings', 'serverName']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label="uTLS"
         name={['streamSettings', 'realitySettings', 'fingerprint']}
       >
         <Select options={UTLS_OPTIONS} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.xray.outboundForm.shortId')}
         name={['streamSettings', 'realitySettings', 'shortId']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.spiderX')}
         name={['streamSettings', 'realitySettings', 'spiderX']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.publicKey')}
         name={['streamSettings', 'realitySettings', 'publicKey']}
       >
         <Input.TextArea autoSize={{ minRows: 2 }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.mldsa65Verify')}
         name={['streamSettings', 'realitySettings', 'mldsa65Verify']}
       >
         <Input.TextArea autoSize={{ minRows: 2 }} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 15 - 13
frontend/src/pages/xray/outbounds/security/tls.tsx

@@ -1,5 +1,7 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Select } from 'antd';
+import { Input, Select } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 import { ALPN_OPTIONS, UTLS_OPTIONS } from '../outbound-form-constants';
 
@@ -7,13 +9,13 @@ export default function TlsForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label="SNI"
         name={['streamSettings', 'tlsSettings', 'serverName']}
       >
         <Input placeholder={t('pages.xray.outboundForm.serverNamePlaceholder')} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label="uTLS"
         name={['streamSettings', 'tlsSettings', 'fingerprint']}
       >
@@ -22,31 +24,31 @@ export default function TlsForm() {
           placeholder={t('none')}
           options={[{ value: '', label: t('none') }, ...UTLS_OPTIONS]}
         />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label="ALPN"
         name={['streamSettings', 'tlsSettings', 'alpn']}
       >
         <Select mode="multiple" options={ALPN_OPTIONS} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label="ECH"
         name={['streamSettings', 'tlsSettings', 'echConfigList']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.xray.outboundForm.verifyPeerName')}
         name={['streamSettings', 'tlsSettings', 'verifyPeerCertByName']}
       >
         <Input placeholder="cloudflare-dns.com" />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.xray.outboundForm.pinnedSha256')}
         name={['streamSettings', 'tlsSettings', 'pinnedPeerCertSha256']}
       >
         <Input placeholder="base64 SHA256" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 10 - 8
frontend/src/pages/xray/outbounds/transport/grpc.tsx

@@ -1,29 +1,31 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch } from 'antd';
+import { Input, Switch } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function GrpcForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.form.serviceName')}
         name={['streamSettings', 'grpcSettings', 'serviceName']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.authority')}
         name={['streamSettings', 'grpcSettings', 'authority']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.multiMode')}
         name={['streamSettings', 'grpcSettings', 'multiMode']}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 8 - 7
frontend/src/pages/xray/outbounds/transport/httpupgrade.tsx

@@ -1,30 +1,31 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input } from 'antd';
+import { Input } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function HttpUpgradeForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('host')}
         name={['streamSettings', 'httpupgradeSettings', 'host']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('path')}
         name={['streamSettings', 'httpupgradeSettings', 'path']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.headers')}
         name={['streamSettings', 'httpupgradeSettings', 'headers']}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 78 - 104
frontend/src/pages/xray/outbounds/transport/hysteria.tsx

@@ -1,134 +1,108 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
-const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+const MASQ = ['streamSettings', 'hysteriaSettings', 'masquerade'];
+const MASQ_DOT = 'streamSettings.hysteriaSettings.masquerade';
 
-export default function HysteriaForm({ form }: { form: FormInstance }) {
+export default function HysteriaForm() {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const masquerade = useWatch({ control, name: MASQ_DOT }) as { type?: string } | undefined;
   return (
     <>
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.form.version')}
         name={['streamSettings', 'hysteriaSettings', 'version']}
       >
         <InputNumber min={2} max={2} disabled style={{ width: '100%' }} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.xray.outboundForm.authPassword')}
         name={['streamSettings', 'hysteriaSettings', 'auth']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.udpIdleTimeout')}
         name={['streamSettings', 'hysteriaSettings', 'udpIdleTimeout']}
       >
         <InputNumber min={2} max={600} style={{ width: '100%' }} />
-      </Form.Item>
+      </FormField>
 
       <Form.Item label={t('pages.inbounds.form.masquerade')}>
-        <Form.Item shouldUpdate noStyle>
-          {() => {
-            const m = form.getFieldValue(MASQ_PATH);
-            return (
-              <Switch
-                checked={!!m}
-                onChange={(checked) =>
-                  form.setFieldValue(
-                    MASQ_PATH,
-                    checked
-                      ? {
-                        type: '', dir: '', url: '',
-                        rewriteHost: false, insecure: false,
-                        content: '', headers: {}, statusCode: 0,
-                      }
-                      : undefined,
-                  )
+        <Switch
+          checked={!!masquerade}
+          onChange={(checked) =>
+            setValue(
+              MASQ_DOT,
+              checked
+                ? {
+                  type: '', dir: '', url: '',
+                  rewriteHost: false, insecure: false,
+                  content: '', headers: {}, statusCode: 0,
                 }
-              />
-            );
-          }}
-        </Form.Item>
+                : undefined,
+            )
+          }
+        />
       </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const m = form.getFieldValue(MASQ_PATH) as { type?: string } | undefined;
-          if (!m) return null;
-          return (
+      {masquerade && (
+        <>
+          <FormField label={t('pages.inbounds.form.type')} name={[...MASQ, 'type']}>
+            <Select
+              options={[
+                { value: '', label: 'default (404 page)' },
+                { value: 'proxy', label: 'proxy (reverse proxy)' },
+                { value: 'file', label: 'file (serve directory)' },
+                { value: 'string', label: 'string (fixed body)' },
+              ]}
+            />
+          </FormField>
+          {masquerade.type === 'proxy' && (
             <>
-              <Form.Item
-                label={t('pages.inbounds.form.type')}
-                name={[...MASQ_PATH, 'type']}
+              <FormField label={t('pages.inbounds.form.upstreamUrl')} name={[...MASQ, 'url']}>
+                <Input placeholder="https://www.example.com" />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.rewriteHost')}
+                name={[...MASQ, 'rewriteHost']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.skipTlsVerify')}
+                name={[...MASQ, 'insecure']}
+                valueProp="checked"
               >
-                <Select
-                  options={[
-                    { value: '', label: 'default (404 page)' },
-                    { value: 'proxy', label: 'proxy (reverse proxy)' },
-                    { value: 'file', label: 'file (serve directory)' },
-                    { value: 'string', label: 'string (fixed body)' },
-                  ]}
-                />
-              </Form.Item>
-              {m.type === 'proxy' && (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.upstreamUrl')}
-                    name={[...MASQ_PATH, 'url']}
-                  >
-                    <Input placeholder="https://www.example.com" />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.rewriteHost')}
-                    name={[...MASQ_PATH, 'rewriteHost']}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.skipTlsVerify')}
-                    name={[...MASQ_PATH, 'insecure']}
-                    valuePropName="checked"
-                  >
-                    <Switch />
-                  </Form.Item>
-                </>
-              )}
-              {m.type === 'file' && (
-                <Form.Item
-                  label={t('pages.inbounds.form.directory')}
-                  name={[...MASQ_PATH, 'dir']}
-                >
-                  <Input placeholder="/var/www/html" />
-                </Form.Item>
-              )}
-              {m.type === 'string' && (
-                <>
-                  <Form.Item
-                    label={t('pages.inbounds.form.statusCode')}
-                    name={[...MASQ_PATH, 'statusCode']}
-                  >
-                    <InputNumber min={0} max={599} style={{ width: '100%' }} />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.body')}
-                    name={[...MASQ_PATH, 'content']}
-                  >
-                    <Input.TextArea autoSize={{ minRows: 3 }} />
-                  </Form.Item>
-                  <Form.Item
-                    label={t('pages.inbounds.form.headers')}
-                    name={[...MASQ_PATH, 'headers']}
-                  >
-                    <HeaderMapEditor mode="v1" />
-                  </Form.Item>
-                </>
-              )}
+                <Switch />
+              </FormField>
             </>
-          );
-        }}
-      </Form.Item>
+          )}
+          {masquerade.type === 'file' && (
+            <FormField label={t('pages.inbounds.form.directory')} name={[...MASQ, 'dir']}>
+              <Input placeholder="/var/www/html" />
+            </FormField>
+          )}
+          {masquerade.type === 'string' && (
+            <>
+              <FormField label={t('pages.inbounds.form.statusCode')} name={[...MASQ, 'statusCode']}>
+                <InputNumber min={0} max={599} style={{ width: '100%' }} />
+              </FormField>
+              <FormField label={t('pages.inbounds.form.body')} name={[...MASQ, 'content']}>
+                <Input.TextArea autoSize={{ minRows: 3 }} />
+              </FormField>
+              <FormField label={t('pages.inbounds.form.headers')} name={[...MASQ, 'headers']}>
+                <HeaderMapEditor mode="v1" />
+              </FormField>
+            </>
+          )}
+        </>
+      )}
     </>
   );
 }

+ 15 - 13
frontend/src/pages/xray/outbounds/transport/kcp.tsx

@@ -1,40 +1,42 @@
 import { useTranslation } from 'react-i18next';
-import { Form, InputNumber } from 'antd';
+import { InputNumber } from 'antd';
+
+import { FormField } from '@/components/form/rhf';
 
 export default function KcpForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
+      <FormField label="MTU" name={['streamSettings', 'kcpSettings', 'mtu']}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
+      </FormField>
+      <FormField label={t('pages.inbounds.form.ttiMs')} name={['streamSettings', 'kcpSettings', 'tti']}>
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.uplinkMbps')}
         name={['streamSettings', 'kcpSettings', 'uplinkCapacity']}
       >
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.downlinkMbps')}
         name={['streamSettings', 'kcpSettings', 'downlinkCapacity']}
       >
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.cwndMultiplier')}
         name={['streamSettings', 'kcpSettings', 'cwndMultiplier']}
       >
         <InputNumber min={1} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.maxSendingWindow')}
         name={['streamSettings', 'kcpSettings', 'maxSendingWindow']}
       >
         <InputNumber min={0} />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 43 - 48
frontend/src/pages/xray/outbounds/transport/mux.tsx

@@ -1,63 +1,58 @@
 import { useTranslation } from 'react-i18next';
-import { Form, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { FormField } from '@/components/form/rhf';
 
 import { isMuxAllowed } from '../outbound-form-helpers';
 
 interface MuxFormProps {
-  form: FormInstance<OutboundFormValues>;
   protocol: string;
   network: string;
 }
 
-export default function MuxForm({ form, protocol, network }: MuxFormProps) {
+export default function MuxForm({ protocol, network }: MuxFormProps) {
   const { t } = useTranslation();
-  const flow = (form.getFieldValue(['settings', 'flow']) ?? '') as string;
+  const { control } = useFormContext();
+  const flow = (useWatch({ control, name: 'settings.flow' }) ?? '') as string;
+  const muxEnabled = !!useWatch({ control, name: 'mux.enabled' });
   if (!isMuxAllowed(protocol, flow, network)) return null;
   return (
-    <Form.Item shouldUpdate noStyle>
-      {() => {
-        const muxEnabled = !!form.getFieldValue(['mux', 'enabled']);
-        return (
-          <>
-            <Form.Item
-              label={t('pages.settings.mux')}
-              name={['mux', 'enabled']}
-              valuePropName="checked"
-            >
-              <Switch />
-            </Form.Item>
-            {muxEnabled && (
-              <>
-                <Form.Item
-                  label={t('pages.settings.subFormats.concurrency')}
-                  name={['mux', 'concurrency']}
-                >
-                  <InputNumber min={-1} max={1024} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.settings.subFormats.xudpConcurrency')}
-                  name={['mux', 'xudpConcurrency']}
-                >
-                  <InputNumber min={-1} max={1024} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.settings.subFormats.xudpUdp443')}
-                  name={['mux', 'xudpProxyUDP443']}
-                >
-                  <Select
-                    options={['reject', 'allow', 'skip'].map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-              </>
-            )}
-          </>
-        );
-      }}
-    </Form.Item>
+    <>
+      <FormField
+        label={t('pages.settings.mux')}
+        name={['mux', 'enabled']}
+        valueProp="checked"
+      >
+        <Switch />
+      </FormField>
+      {muxEnabled && (
+        <>
+          <FormField
+            label={t('pages.settings.subFormats.concurrency')}
+            name={['mux', 'concurrency']}
+          >
+            <InputNumber min={-1} max={1024} />
+          </FormField>
+          <FormField
+            label={t('pages.settings.subFormats.xudpConcurrency')}
+            name={['mux', 'xudpConcurrency']}
+          >
+            <InputNumber min={-1} max={1024} />
+          </FormField>
+          <FormField
+            label={t('pages.settings.subFormats.xudpUdp443')}
+            name={['mux', 'xudpProxyUDP443']}
+          >
+            <Select
+              options={['reject', 'allow', 'skip'].map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+        </>
+      )}
+    </>
   );
 }

+ 99 - 126
frontend/src/pages/xray/outbounds/transport/raw.tsx

@@ -1,136 +1,109 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, Switch, type FormInstance } from 'antd';
+import { Form, Input, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { FormField } from '@/components/form/rhf';
 
-export default function RawForm({ form }: { form: FormInstance<OutboundFormValues> }) {
+export default function RawForm() {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const type = (useWatch({
+    control,
+    name: 'streamSettings.tcpSettings.header.type',
+  }) ?? 'none') as string;
   return (
-    <Form.Item shouldUpdate noStyle>
-      {() => {
-        const type =
-          form.getFieldValue([
-            'streamSettings',
-            'tcpSettings',
-            'header',
-            'type',
-          ]) ?? 'none';
-        return (
-          <>
-            <Form.Item label={`HTTP ${t('camouflage')}`}>
-              <Switch
-                checked={type === 'http'}
-                onChange={(checked) =>
-                  form.setFieldValue(
-                    ['streamSettings', 'tcpSettings', 'header'],
-                    checked
-                      ? {
-                        type: 'http',
-                        request: {
-                          version: '1.1',
-                          method: 'GET',
-                          path: ['/'],
-                          headers: {},
-                        },
-                        response: {
-                          version: '1.1',
-                          status: '200',
-                          reason: 'OK',
-                          headers: {},
-                        },
-                      }
-                      : { type: 'none' },
-                  )
+    <>
+      <Form.Item label={`HTTP ${t('camouflage')}`}>
+        <Switch
+          checked={type === 'http'}
+          onChange={(checked) =>
+            setValue(
+              'streamSettings.tcpSettings.header',
+              checked
+                ? {
+                  type: 'http',
+                  request: {
+                    version: '1.1',
+                    method: 'GET',
+                    path: ['/'],
+                    headers: {},
+                  },
+                  response: {
+                    version: '1.1',
+                    status: '200',
+                    reason: 'OK',
+                    headers: {},
+                  },
                 }
-              />
-            </Form.Item>
-            {type === 'http' && (
-              <>
-                <Form.Item
-                  label={t('pages.inbounds.form.requestVersion')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'request', 'version',
-                  ]}
-                >
-                  <Input placeholder="1.1" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.requestMethod')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'request', 'method',
-                  ]}
-                >
-                  <Input placeholder="GET" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.requestPath')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'request', 'path',
-                  ]}
-                  getValueProps={(v) => ({ value: Array.isArray(v) ? v.join(',') : v })}
-                  getValueFromEvent={(e) => {
-                    const raw = (e?.target?.value ?? '') as string;
-                    const parts = raw.split(',').map((s) => s.trim()).filter(Boolean);
-                    return parts.length > 0 ? parts : ['/'];
-                  }}
-                >
-                  <Input placeholder="/" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.requestHeaders')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'request', 'headers',
-                  ]}
-                >
-                  <HeaderMapEditor mode="v2" />
-                </Form.Item>
+                : { type: 'none' },
+            )
+          }
+        />
+      </Form.Item>
+      {type === 'http' && (
+        <>
+          <FormField
+            label={t('pages.inbounds.form.requestVersion')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'version']}
+          >
+            <Input placeholder="1.1" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestMethod')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'method']}
+          >
+            <Input placeholder="GET" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestPath')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'path']}
+            transform={{
+              input: (v) => (Array.isArray(v) ? v.join(',') : v),
+              output: (raw) => {
+                const parts = String(raw ?? '')
+                  .split(',')
+                  .map((s) => s.trim())
+                  .filter(Boolean);
+                return parts.length > 0 ? parts : ['/'];
+              },
+            }}
+          >
+            <Input placeholder="/" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.requestHeaders')}
+            name={['streamSettings', 'tcpSettings', 'header', 'request', 'headers']}
+          >
+            <HeaderMapEditor mode="v2" />
+          </FormField>
 
-                <Form.Item
-                  label={t('pages.inbounds.form.responseVersion')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'response', 'version',
-                  ]}
-                >
-                  <Input placeholder="1.1" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.responseStatus')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'response', 'status',
-                  ]}
-                >
-                  <Input placeholder="200" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.responseReason')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'response', 'reason',
-                  ]}
-                >
-                  <Input placeholder="OK" />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.responseHeaders')}
-                  name={[
-                    'streamSettings', 'tcpSettings', 'header',
-                    'response', 'headers',
-                  ]}
-                >
-                  <HeaderMapEditor mode="v2" />
-                </Form.Item>
-              </>
-            )}
-          </>
-        );
-      }}
-    </Form.Item>
+          <FormField
+            label={t('pages.inbounds.form.responseVersion')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'version']}
+          >
+            <Input placeholder="1.1" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseStatus')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'status']}
+          >
+            <Input placeholder="200" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseReason')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'reason']}
+          >
+            <Input placeholder="OK" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.responseHeaders')}
+            name={['streamSettings', 'tcpSettings', 'header', 'response', 'headers']}
+          >
+            <HeaderMapEditor mode="v2" />
+          </FormField>
+        </>
+      )}
+    </>
   );
 }

+ 192 - 208
frontend/src/pages/xray/outbounds/transport/sockopt.tsx

@@ -1,225 +1,209 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { Form, Input, InputNumber, Select, Switch } from 'antd';
+import { Controller, useFormContext, useWatch } from 'react-hook-form';
 
-import { CustomSockoptList } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
+import { SockoptCustomField } from '@/lib/xray/forms/fields';
 import { DOMAIN_STRATEGY_OPTION, TCP_CONGESTION_OPTION } from '@/schemas/primitives';
 import { HappyEyeballsSchema, SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 
 import { ADDRESS_PORT_STRATEGY_OPTIONS } from '../outbound-form-constants';
 
 export default function SockoptForm({
-  form,
   outboundTags = [],
 }: {
-  form: FormInstance<OutboundFormValues>;
   outboundTags?: string[];
 }) {
   const { t } = useTranslation();
+  const { control, setValue } = useFormContext();
+  const sockopt = useWatch({ control, name: 'streamSettings.sockopt' });
+  const hasSockopt = !!sockopt;
+  const dialerProxy = (useWatch({ control, name: 'streamSettings.sockopt.dialerProxy' }) ?? '') as string;
+  const happyEyeballs = useWatch({ control, name: 'streamSettings.sockopt.happyEyeballs' });
+  const hasHe = happyEyeballs != null;
+  const dialerProxyOptions = Array.from(
+    new Set([...outboundTags, dialerProxy].filter(Boolean)),
+  ).map((tg) => ({ value: tg, label: tg }));
   return (
-    <Form.Item shouldUpdate noStyle>
-      {() => {
-        const hasSockopt = !!form.getFieldValue([
-          'streamSettings',
-          'sockopt',
-        ]);
-        const dialerProxy = (form.getFieldValue([
-          'streamSettings',
-          'sockopt',
-          'dialerProxy',
-        ]) ?? '') as string;
-        const dialerProxyOptions = Array.from(
-          new Set([...outboundTags, dialerProxy].filter(Boolean)),
-        ).map((tg) => ({ value: tg, label: tg }));
-        return (
-          <>
-            <Form.Item label={t('pages.xray.outboundForm.sockopts')}>
-              <Switch
-                checked={hasSockopt}
-                onChange={(checked) => {
-                  form.setFieldValue(
-                    ['streamSettings', 'sockopt'],
-                    checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
-                  );
-                }}
-              />
-            </Form.Item>
-            {hasSockopt && (
-              <>
-                <Form.Item
-                  label={t('pages.inbounds.form.dialerProxy')}
-                  name={['streamSettings', 'sockopt', 'dialerProxy']}
-                  tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
-                >
-                  <Select
-                    allowClear
-                    showSearch
-                    placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
-                    options={dialerProxyOptions}
-                  />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.wireguard.domainStrategy')}
-                  name={['streamSettings', 'sockopt', 'domainStrategy']}
-                >
-                  <Select
-                    options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.addressPortStrategy')}
-                  name={['streamSettings', 'sockopt', 'addressPortStrategy']}
-                >
-                  <Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.keepAliveInterval')}
-                  name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.tcpFastOpen')}
-                  name={['streamSettings', 'sockopt', 'tcpFastOpen']}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.multipathTcp')}
-                  name={['streamSettings', 'sockopt', 'tcpMptcp']}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.penetrate')}
-                  name={['streamSettings', 'sockopt', 'penetrate']}
-                  valuePropName="checked"
-                >
-                  <Switch />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.markFwmark')}
-                  name={['streamSettings', 'sockopt', 'mark']}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.interface')}
-                  name={['streamSettings', 'sockopt', 'interface']}
-                >
-                  <Input />
-                </Form.Item>
-                <Form.Item
-                  label="TProxy"
-                  name={['streamSettings', 'sockopt', 'tproxy']}
-                >
-                  <Select
-                    options={[
-                      { value: 'off', label: 'off' },
-                      { value: 'redirect', label: 'redirect' },
-                      { value: 'tproxy', label: 'tproxy' },
-                    ]}
-                  />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.tcpCongestion')}
-                  name={['streamSettings', 'sockopt', 'tcpcongestion']}
-                >
-                  <Select
-                    options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
-                      value: v,
-                      label: v,
-                    }))}
-                  />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
-                  name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
-                  name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.tcpMaxSeg')}
-                  name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item
-                  label={t('pages.inbounds.form.tcpWindowClamp')}
-                  name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
-                  tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
-                >
-                  <InputNumber min={0} />
-                </Form.Item>
-                <Form.Item shouldUpdate noStyle>
-                  {() => {
-                    const he = form.getFieldValue([
-                      'streamSettings', 'sockopt', 'happyEyeballs',
-                    ]);
-                    const hasHe = he != null;
-                    return (
-                      <>
-                        <Form.Item label="Happy Eyeballs">
-                          <Switch
-                            checked={hasHe}
-                            onChange={(v) => {
-                              form.setFieldValue(
-                                ['streamSettings', 'sockopt', 'happyEyeballs'],
-                                v ? HappyEyeballsSchema.parse({}) : undefined,
-                              );
-                            }}
-                          />
-                        </Form.Item>
-                        {hasHe && (
-                          <>
-                            <Form.Item
-                              label={t('pages.inbounds.form.tryDelayMs')}
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
-                            >
-                              <InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
-                            </Form.Item>
-                            <Form.Item
-                              label={t('pages.inbounds.form.prioritizeIPv6')}
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
-                              valuePropName="checked"
-                            >
-                              <Switch />
-                            </Form.Item>
-                            <Form.Item
-                              label={t('pages.inbounds.form.interleave')}
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
-                            >
-                              <InputNumber min={1} />
-                            </Form.Item>
-                            <Form.Item
-                              label={t('pages.inbounds.form.maxConcurrentTry')}
-                              name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
-                            >
-                              <InputNumber min={0} />
-                            </Form.Item>
-                          </>
-                        )}
-                      </>
-                    );
-                  }}
-                </Form.Item>
-                <CustomSockoptList />
-              </>
+    <>
+      <Form.Item label={t('pages.xray.outboundForm.sockopts')}>
+        <Switch
+          checked={hasSockopt}
+          onChange={(checked) => {
+            setValue(
+              'streamSettings.sockopt',
+              checked ? SockoptStreamSettingsSchema.parse({}) : undefined,
+            );
+          }}
+        />
+      </Form.Item>
+      {hasSockopt && (
+        <>
+          <FormField
+            label={t('pages.inbounds.form.dialerProxy')}
+            name={['streamSettings', 'sockopt', 'dialerProxy']}
+            tooltip={t('pages.xray.outboundForm.dialerProxyHint')}
+          >
+            <Select
+              allowClear
+              showSearch
+              placeholder={t('pages.xray.outboundForm.dialerProxyPlaceholder')}
+              options={dialerProxyOptions}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.xray.wireguard.domainStrategy')}
+            name={['streamSettings', 'sockopt', 'domainStrategy']}
+          >
+            <Select
+              options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.addressPortStrategy')}
+            name={['streamSettings', 'sockopt', 'addressPortStrategy']}
+          >
+            <Select options={ADDRESS_PORT_STRATEGY_OPTIONS} />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.keepAliveInterval')}
+            name={['streamSettings', 'sockopt', 'tcpKeepAliveInterval']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.tcpFastOpen')}
+            name={['streamSettings', 'sockopt', 'tcpFastOpen']}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.multipathTcp')}
+            name={['streamSettings', 'sockopt', 'tcpMptcp']}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.penetrate')}
+            name={['streamSettings', 'sockopt', 'penetrate']}
+            valueProp="checked"
+          >
+            <Switch />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.markFwmark')}
+            name={['streamSettings', 'sockopt', 'mark']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.interface')}
+            name={['streamSettings', 'sockopt', 'interface']}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            label="TProxy"
+            name={['streamSettings', 'sockopt', 'tproxy']}
+          >
+            <Select
+              options={[
+                { value: 'off', label: 'off' },
+                { value: 'redirect', label: 'redirect' },
+                { value: 'tproxy', label: 'tproxy' },
+              ]}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.tcpCongestion')}
+            name={['streamSettings', 'sockopt', 'tcpcongestion']}
+          >
+            <Select
+              options={Object.values(TCP_CONGESTION_OPTION).map((v) => ({
+                value: v,
+                label: v,
+              }))}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.tcpUserTimeoutMs')}
+            name={['streamSettings', 'sockopt', 'tcpUserTimeout']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.tcpKeepAliveIdleS')}
+            name={['streamSettings', 'sockopt', 'tcpKeepAliveIdle']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.tcpMaxSeg')}
+            name={['streamSettings', 'sockopt', 'tcpMaxSeg']}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.tcpWindowClamp')}
+            name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
+            tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
+          >
+            <InputNumber min={0} />
+          </FormField>
+          <Form.Item label="Happy Eyeballs">
+            <Switch
+              checked={hasHe}
+              onChange={(v) => {
+                setValue(
+                  'streamSettings.sockopt.happyEyeballs',
+                  v ? HappyEyeballsSchema.parse({}) : undefined,
+                );
+              }}
+            />
+          </Form.Item>
+          {hasHe && (
+            <>
+              <FormField
+                label={t('pages.inbounds.form.tryDelayMs')}
+                name={['streamSettings', 'sockopt', 'happyEyeballs', 'tryDelayMs']}
+              >
+                <InputNumber min={0} placeholder="0 (disabled) — 250 recommended" />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.prioritizeIPv6')}
+                name={['streamSettings', 'sockopt', 'happyEyeballs', 'prioritizeIPv6']}
+                valueProp="checked"
+              >
+                <Switch />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.interleave')}
+                name={['streamSettings', 'sockopt', 'happyEyeballs', 'interleave']}
+              >
+                <InputNumber min={1} />
+              </FormField>
+              <FormField
+                label={t('pages.inbounds.form.maxConcurrentTry')}
+                name={['streamSettings', 'sockopt', 'happyEyeballs', 'maxConcurrentTry']}
+              >
+                <InputNumber min={0} />
+              </FormField>
+            </>
+          )}
+          <Controller
+            control={control}
+            name="streamSettings.sockopt.customSockopt"
+            render={({ field }) => (
+              <SockoptCustomField value={field.value} onChange={field.onChange} />
             )}
-          </>
-        );
-      }}
-    </Form.Item>
+          />
+        </>
+      )}
+    </>
   );
 }

+ 10 - 9
frontend/src/pages/xray/outbounds/transport/ws.tsx

@@ -1,30 +1,31 @@
 import { useTranslation } from 'react-i18next';
-import { Form, Input, InputNumber } from 'antd';
+import { Input, InputNumber } from 'antd';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 
 export default function WsForm() {
   const { t } = useTranslation();
   return (
     <>
-      <Form.Item label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
+      <FormField label={t('host')} name={['streamSettings', 'wsSettings', 'host']}>
         <Input />
-      </Form.Item>
-      <Form.Item label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
+      </FormField>
+      <FormField label={t('path')} name={['streamSettings', 'wsSettings', 'path']}>
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.heartbeatPeriod')}
         name={['streamSettings', 'wsSettings', 'heartbeatPeriod']}
       >
         <InputNumber min={0} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.headers')}
         name={['streamSettings', 'wsSettings', 'headers']}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
     </>
   );
 }

+ 226 - 290
frontend/src/pages/xray/outbounds/transport/xhttp.tsx

@@ -1,151 +1,141 @@
 import { useTranslation } from 'react-i18next';
-import { AutoComplete, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
+import { AutoComplete, Input, InputNumber, Select, Switch } from 'antd';
+import { useFormContext, useWatch } from 'react-hook-form';
 
 import { HeaderMapEditor } from '@/components/form';
+import { FormField } from '@/components/form/rhf';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
-import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
 
 import { MODE_OPTIONS } from '../outbound-form-constants';
 
 interface XhttpFormProps {
-  form: FormInstance<OutboundFormValues>;
   onXmuxToggle: (checked: boolean) => void;
 }
 
-export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
+function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
+  return async (value: unknown): Promise<true | string> => {
+    try {
+      await fn(undefined, value);
+      return true;
+    } catch (e) {
+      return (e as Error).message;
+    }
+  };
+}
+
+const XH = 'streamSettings.xhttpSettings';
+
+export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const { t } = useTranslation();
+  const { control } = useFormContext();
+  const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
+  const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
+  const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
+  const table = useWatch({ control, name: `${XH}.sessionIDTable` });
+  const seqPlacement = useWatch({ control, name: `${XH}.seqPlacement` }) as string | undefined;
+  const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
+  const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
+
   return (
     <>
-      <Form.Item
-        label={t('host')}
-        name={['streamSettings', 'xhttpSettings', 'host']}
-      >
+      <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
         <Input />
-      </Form.Item>
-      <Form.Item
-        label={t('path')}
-        name={['streamSettings', 'xhttpSettings', 'path']}
-      >
+      </FormField>
+      <FormField label={t('path')} name={['streamSettings', 'xhttpSettings', 'path']}>
         <Input />
-      </Form.Item>
-      <Form.Item
-        label={t('pages.inbounds.info.mode')}
-        name={['streamSettings', 'xhttpSettings', 'mode']}
-      >
+      </FormField>
+      <FormField label={t('pages.inbounds.info.mode')} name={['streamSettings', 'xhttpSettings', 'mode']}>
         <Select options={MODE_OPTIONS} />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.paddingBytes')}
         name={['streamSettings', 'xhttpSettings', 'xPaddingBytes']}
       >
         <Input />
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      <FormField
         label={t('pages.inbounds.form.headers')}
         name={['streamSettings', 'xhttpSettings', 'headers']}
       >
         <HeaderMapEditor mode="v1" />
-      </Form.Item>
+      </FormField>
 
       {/* Padding obfs sub-section: gated by a Switch.
           When on, four extra knobs (key/header/placement/
           method) tune how Xray injects random padding to
           disguise the post body shape. */}
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.form.paddingObfsMode')}
         name={['streamSettings', 'xhttpSettings', 'xPaddingObfsMode']}
-        valuePropName="checked"
+        valueProp="checked"
       >
         <Switch />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const obfs = !!form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'xPaddingObfsMode',
-          ]);
-          if (!obfs) return null;
-          return (
-            <>
-              <Form.Item
-                label={t('pages.inbounds.form.paddingKey')}
-                name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
-              >
-                <Input placeholder="x_padding" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.paddingHeader')}
-                name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
-              >
-                <Input placeholder="X-Padding" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.paddingPlacement')}
-                name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (queryInHeader)' },
-                    { value: 'queryInHeader', label: 'queryInHeader' },
-                    { value: 'header', label: 'header' },
-                    { value: 'cookie', label: 'cookie' },
-                    { value: 'query', label: 'query' },
-                  ]}
-                />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.paddingMethod')}
-                name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (repeat-x)' },
-                    { value: 'repeat-x', label: 'repeat-x' },
-                    { value: 'tokenish', label: 'tokenish' },
-                  ]}
-                />
-              </Form.Item>
-            </>
-          );
-        }}
-      </Form.Item>
+      </FormField>
+      {obfs && (
+        <>
+          <FormField
+            label={t('pages.inbounds.form.paddingKey')}
+            name={['streamSettings', 'xhttpSettings', 'xPaddingKey']}
+          >
+            <Input placeholder="x_padding" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.paddingHeader')}
+            name={['streamSettings', 'xhttpSettings', 'xPaddingHeader']}
+          >
+            <Input placeholder="X-Padding" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.paddingPlacement')}
+            name={['streamSettings', 'xhttpSettings', 'xPaddingPlacement']}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (queryInHeader)' },
+                { value: 'queryInHeader', label: 'queryInHeader' },
+                { value: 'header', label: 'header' },
+                { value: 'cookie', label: 'cookie' },
+                { value: 'query', label: 'query' },
+              ]}
+            />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.paddingMethod')}
+            name={['streamSettings', 'xhttpSettings', 'xPaddingMethod']}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (repeat-x)' },
+                { value: 'repeat-x', label: 'repeat-x' },
+                { value: 'tokenish', label: 'tokenish' },
+              ]}
+            />
+          </FormField>
+        </>
+      )}
 
-      <Form.Item
-        noStyle
-        shouldUpdate={(prev, curr) =>
-          prev?.streamSettings?.xhttpSettings?.mode !==
-          curr?.streamSettings?.xhttpSettings?.mode
-        }
+      <FormField
+        label={t('pages.inbounds.form.uplinkHttpMethod')}
+        name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
       >
-        {() => {
-          const mode = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'mode',
-          ]);
-          return (
-            <Form.Item
-              label={t('pages.inbounds.form.uplinkHttpMethod')}
-              name={['streamSettings', 'xhttpSettings', 'uplinkHTTPMethod']}
-            >
-              <Select
-                placeholder="Default (POST)"
-                options={[
-                  { value: '', label: 'Default (POST)' },
-                  { value: 'POST', label: 'POST' },
-                  { value: 'PUT', label: 'PUT' },
-                  { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
-                ]}
-              />
-            </Form.Item>
-          );
-        }}
-      </Form.Item>
+        <Select
+          placeholder="Default (POST)"
+          options={[
+            { value: '', label: 'Default (POST)' },
+            { value: 'POST', label: 'POST' },
+            { value: 'PUT', label: 'PUT' },
+            { value: 'GET', label: 'GET (packet-up only)', disabled: mode !== 'packet-up' },
+          ]}
+        />
+      </FormField>
 
       {/* Session + sequence + uplinkData placements:
           three orthogonal slots Xray uses to thread
           request metadata through the transport
           (path / header / cookie / query). Key field
           only matters when placement is not 'path'. */}
-      <Form.Item
+      <FormField
         label={t('pages.inbounds.form.sessionPlacement')}
         name={['streamSettings', 'xhttpSettings', 'sessionIDPlacement']}
       >
@@ -159,54 +149,38 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
             { value: 'query', label: 'query' },
           ]}
         />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const placement = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'sessionIDPlacement',
-          ]);
-          if (!placement || placement === 'path') return null;
-          return (
-            <Form.Item
-              label={t('pages.inbounds.form.sessionKey')}
-              name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
-            >
-              <Input placeholder="x_session" />
-            </Form.Item>
-          );
-        }}
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      {sessionPlacement && sessionPlacement !== 'path' && (
+        <FormField
+          label={t('pages.inbounds.form.sessionKey')}
+          name={['streamSettings', 'xhttpSettings', 'sessionIDKey']}
+        >
+          <Input placeholder="x_session" />
+        </FormField>
+      )}
+      <FormField
         label={t('pages.inbounds.form.sessionIDTable')}
         tooltip={t('pages.inbounds.form.sessionIDTableHint')}
         name={['streamSettings', 'xhttpSettings', 'sessionIDTable']}
-        rules={[{ validator: validateSessionIDTable }]}
+        rules={{ validate: antdValidatorToRhf(validateSessionIDTable) }}
       >
         <AutoComplete
           allowClear
           options={XHTTP_SESSION_ID_TABLES.map((v) => ({ value: v }))}
           placeholder="Base62"
         />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const table = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'sessionIDTable',
-          ]);
-          if (!table) return null;
-          return (
-            <Form.Item
-              label={t('pages.inbounds.form.sessionIDLength')}
-              tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
-              name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
-              rules={[{ validator: validateSessionIDLength }]}
-            >
-              <Input placeholder="8-16" />
-            </Form.Item>
-          );
-        }}
-      </Form.Item>
-      <Form.Item
+      </FormField>
+      {!!table && (
+        <FormField
+          label={t('pages.inbounds.form.sessionIDLength')}
+          tooltip={t('pages.inbounds.form.sessionIDLengthHint')}
+          name={['streamSettings', 'xhttpSettings', 'sessionIDLength']}
+          rules={{ validate: antdValidatorToRhf(validateSessionIDLength) }}
+        >
+          <Input placeholder="8-16" />
+        </FormField>
+      )}
+      <FormField
         label={t('pages.inbounds.form.sequencePlacement')}
         name={['streamSettings', 'xhttpSettings', 'seqPlacement']}
       >
@@ -220,168 +194,130 @@ export default function XhttpForm({ form, onXmuxToggle }: XhttpFormProps) {
             { value: 'query', label: 'query' },
           ]}
         />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const placement = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'seqPlacement',
-          ]);
-          if (!placement || placement === 'path') return null;
-          return (
-            <Form.Item
-              label={t('pages.inbounds.form.sequenceKey')}
-              name={['streamSettings', 'xhttpSettings', 'seqKey']}
-            >
-              <Input placeholder="x_seq" />
-            </Form.Item>
-          );
-        }}
-      </Form.Item>
+      </FormField>
+      {seqPlacement && seqPlacement !== 'path' && (
+        <FormField
+          label={t('pages.inbounds.form.sequenceKey')}
+          name={['streamSettings', 'xhttpSettings', 'seqKey']}
+        >
+          <Input placeholder="x_seq" />
+        </FormField>
+      )}
 
       {/* Mode-conditional sub-sections. */}
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const mode = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'mode',
-          ]);
-          if (mode !== 'packet-up' && mode !== 'auto') return null;
-          return (
+      {(mode === 'packet-up' || mode === 'auto') && (
+        <>
+          <FormField
+            label={t('pages.xray.outboundForm.minUploadInterval')}
+            name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
+          >
+            <Input placeholder="e.g. 50-150" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
+            name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
+          >
+            <Input placeholder="1000000" />
+          </FormField>
+          <FormField
+            label={t('pages.inbounds.form.uplinkDataPlacement')}
+            name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
+          >
+            <Select
+              options={[
+                { value: '', label: 'Default (body)' },
+                { value: 'body', label: 'body' },
+                { value: 'header', label: 'header' },
+                { value: 'cookie', label: 'cookie' },
+                { value: 'query', label: 'query' },
+              ]}
+            />
+          </FormField>
+          {uplinkDataPlacement && uplinkDataPlacement !== 'body' && (
             <>
-              <Form.Item
-                label={t('pages.xray.outboundForm.minUploadInterval')}
-                name={['streamSettings', 'xhttpSettings', 'scMinPostsIntervalMs']}
+              <FormField
+                label={t('pages.inbounds.form.uplinkDataKey')}
+                name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
               >
-                <Input placeholder="e.g. 50-150" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxUploadSizeBytes')}
-                name={['streamSettings', 'xhttpSettings', 'scMaxEachPostBytes']}
+                <Input placeholder="x_data" />
+              </FormField>
+              <FormField
+                label={t('pages.xray.outboundForm.uplinkChunkSize')}
+                name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
               >
-                <Input placeholder="1000000" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.inbounds.form.uplinkDataPlacement')}
-                name={['streamSettings', 'xhttpSettings', 'uplinkDataPlacement']}
-              >
-                <Select
-                  options={[
-                    { value: '', label: 'Default (body)' },
-                    { value: 'body', label: 'body' },
-                    { value: 'header', label: 'header' },
-                    { value: 'cookie', label: 'cookie' },
-                    { value: 'query', label: 'query' },
-                  ]}
+                <InputNumber
+                  min={0}
+                  placeholder="0 (unlimited)"
+                  style={{ width: '100%' }}
                 />
-              </Form.Item>
-              <Form.Item shouldUpdate noStyle>
-                {() => {
-                  const place = form.getFieldValue([
-                    'streamSettings', 'xhttpSettings', 'uplinkDataPlacement',
-                  ]);
-                  if (!place || place === 'body') return null;
-                  return (
-                    <>
-                      <Form.Item
-                        label={t('pages.inbounds.form.uplinkDataKey')}
-                        name={['streamSettings', 'xhttpSettings', 'uplinkDataKey']}
-                      >
-                        <Input placeholder="x_data" />
-                      </Form.Item>
-                      <Form.Item
-                        label={t('pages.xray.outboundForm.uplinkChunkSize')}
-                        name={['streamSettings', 'xhttpSettings', 'uplinkChunkSize']}
-                      >
-                        <InputNumber
-                          min={0}
-                          placeholder="0 (unlimited)"
-                          style={{ width: '100%' }}
-                        />
-                      </Form.Item>
-                    </>
-                  );
-                }}
-              </Form.Item>
+              </FormField>
             </>
-          );
-        }}
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          const mode = form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'mode',
-          ]);
-          if (mode !== 'stream-up' && mode !== 'stream-one') return null;
-          return (
-            <Form.Item
-              label={t('pages.xray.outboundForm.noGrpcHeader')}
-              name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
-              valuePropName="checked"
-            >
-              <Switch />
-            </Form.Item>
-          );
-        }}
-      </Form.Item>
+          )}
+        </>
+      )}
+      {(mode === 'stream-up' || mode === 'stream-one') && (
+        <FormField
+          label={t('pages.xray.outboundForm.noGrpcHeader')}
+          name={['streamSettings', 'xhttpSettings', 'noGRPCHeader']}
+          valueProp="checked"
+        >
+          <Switch />
+        </FormField>
+      )}
 
       {/* XMUX is the connection-multiplexing layer
           xHTTP uses to fan out parallel requests over
           a small pool of upstream connections. UI-only
           toggle (enableXmux) hides the 6 nested knobs
           when off. */}
-      <Form.Item
+      <FormField
         label="XMUX"
         name={['streamSettings', 'xhttpSettings', 'enableXmux']}
-        valuePropName="checked"
+        valueProp="checked"
+        onAfterChange={(v) => onXmuxToggle(v as boolean)}
       >
-        <Switch onChange={onXmuxToggle} />
-      </Form.Item>
-      <Form.Item shouldUpdate noStyle>
-        {() => {
-          if (!form.getFieldValue([
-            'streamSettings', 'xhttpSettings', 'enableXmux',
-          ])) return null;
-          return (
-            <>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxConcurrency')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
-              >
-                <Input placeholder="16-32" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxConnections')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
-              >
-                <Input placeholder="0" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxReuseTimes')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
-              >
-                <Input />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxRequestTimes')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
-              >
-                <Input placeholder="600-900" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.maxReusableSecs')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
-              >
-                <Input placeholder="1800-3000" />
-              </Form.Item>
-              <Form.Item
-                label={t('pages.xray.outboundForm.keepAlivePeriod')}
-                name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
-              >
-                <InputNumber min={0} style={{ width: '100%' }} />
-              </Form.Item>
-            </>
-          );
-        }}
-      </Form.Item>
+        <Switch />
+      </FormField>
+      {enableXmux && (
+        <>
+          <FormField
+            label={t('pages.xray.outboundForm.maxConcurrency')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+          >
+            <Input placeholder="16-32" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxConnections')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+          >
+            <Input placeholder="0" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxReuseTimes')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'cMaxReuseTimes']}
+          >
+            <Input />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxRequestTimes')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxRequestTimes']}
+          >
+            <Input placeholder="600-900" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.maxReusableSecs')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hMaxReusableSecs']}
+          >
+            <Input placeholder="1800-3000" />
+          </FormField>
+          <FormField
+            label={t('pages.xray.outboundForm.keepAlivePeriod')}
+            name={['streamSettings', 'xhttpSettings', 'xmux', 'hKeepAlivePeriod']}
+          >
+            <InputNumber min={0} style={{ width: '100%' }} />
+          </FormField>
+        </>
+      )}
     </>
   );
 }

+ 55 - 50
frontend/src/pages/xray/overrides/NordModal.tsx

@@ -2,8 +2,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Divider, Form, Input, message, Modal, Select, Tabs, Tag } from 'antd';
 import { LoginOutlined, SaveOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { HttpUtil } from '@/utils';
+import { FormField } from '@/components/form/rhf';
 import './NordModal.css';
 
 interface NordModalProps {
@@ -44,6 +46,22 @@ interface NordServer {
   cityName?: string;
 }
 
+interface NordFormValues {
+  token: string;
+  manualKey: string;
+  countryId: number | null;
+  cityId: number | null;
+  serverId: number | null;
+}
+
+const EMPTY: NordFormValues = {
+  token: '',
+  manualKey: '',
+  countryId: null,
+  cityId: null,
+  serverId: null,
+};
+
 function loadColor(load: number): string {
   if (load < 30) return 'green';
   if (load < 70) return 'orange';
@@ -63,14 +81,12 @@ export default function NordModal({
   const [messageApi, messageContextHolder] = message.useMessage();
   const [loading, setLoading] = useState(false);
   const [nordData, setNordData] = useState<NordData | null>(null);
-  const [token, setToken] = useState('');
-  const [manualKey, setManualKey] = useState('');
   const [countries, setCountries] = useState<Country[]>([]);
   const [cities, setCities] = useState<City[]>([]);
   const [servers, setServers] = useState<NordServer[]>([]);
-  const [countryId, setCountryId] = useState<number | null>(null);
-  const [cityId, setCityId] = useState<number | null>(null);
-  const [serverId, setServerId] = useState<number | null>(null);
+  const methods = useForm<NordFormValues>({ defaultValues: EMPTY });
+  const cityId = useWatch({ control: methods.control, name: 'cityId' });
+  const serverId = useWatch({ control: methods.control, name: 'serverId' });
 
   const nordOutboundIndex = useMemo(() => {
     const list = templateSettings?.outbounds;
@@ -84,8 +100,8 @@ export default function NordModal({
   }, [cityId, servers]);
 
   useEffect(() => {
-    setServerId(filteredServers.length > 0 ? filteredServers[0].id : null);
-  }, [filteredServers]);
+    methods.setValue('serverId', filteredServers.length > 0 ? filteredServers[0].id : null);
+  }, [filteredServers, methods]);
 
   const fetchCountries = useCallback(async () => {
     const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
@@ -113,7 +129,7 @@ export default function NordModal({
   async function login() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token: methods.getValues('token') });
       if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
@@ -126,7 +142,7 @@ export default function NordModal({
   async function saveKey() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: methods.getValues('manualKey') });
       if (msg?.success && msg.obj) {
         setNordData(JSON.parse(msg.obj));
         await fetchCountries();
@@ -144,14 +160,10 @@ export default function NordModal({
         onRemoveOutbound(nordOutboundIndex);
         onRemoveRoutingRules({ prefix: 'nord-' });
         setNordData(null);
-        setToken('');
-        setManualKey('');
+        methods.reset(EMPTY);
         setCountries([]);
         setCities([]);
         setServers([]);
-        setCountryId(null);
-        setCityId(null);
-        setServerId(null);
       }
     } finally {
       setLoading(false);
@@ -159,12 +171,11 @@ export default function NordModal({
   }
 
   async function fetchServers(newCountryId: number) {
-    setCountryId(newCountryId);
     setLoading(true);
     setServers([]);
     setCities([]);
-    setServerId(null);
-    setCityId(null);
+    methods.setValue('serverId', null);
+    methods.setValue('cityId', null);
     try {
       const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
       if (!msg?.success || !msg.obj) return;
@@ -194,7 +205,8 @@ export default function NordModal({
   }
 
   function buildNordOutbound(): Record<string, unknown> | null {
-    const server = servers.find((s) => s.id === serverId);
+    const selectedServerId = methods.getValues('serverId');
+    const server = servers.find((s) => s.id === selectedServerId);
     if (!server) return null;
     const tech = server.technologies?.find((tt) => tt.id === 35);
     const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
@@ -244,6 +256,7 @@ export default function NordModal({
     <>
       {messageContextHolder}
       <Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
+      <FormProvider {...methods}>
       {nordData == null ? (
         <Tabs
           defaultActiveKey="token"
@@ -258,16 +271,12 @@ export default function NordModal({
                   wrapperCol={{ md: { span: 18 } }}
                   className="mt-20"
                 >
-                  <Form.Item label={t('pages.xray.nord.accessToken')}>
-                    <Input
-                      value={token}
-                      placeholder={t('pages.xray.nord.accessToken')}
-                      onChange={(e) => setToken(e.target.value)}
-                    />
-                    <Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
-                      {t('login')}
-                    </Button>
-                  </Form.Item>
+                  <FormField name="token" label={t('pages.xray.nord.accessToken')}>
+                    <Input placeholder={t('pages.xray.nord.accessToken')} />
+                  </FormField>
+                  <Button type="primary" className="mt-10" loading={loading} icon={<LoginOutlined />} onClick={login}>
+                    {t('login')}
+                  </Button>
                 </Form>
               ),
             },
@@ -281,16 +290,12 @@ export default function NordModal({
                   wrapperCol={{ md: { span: 18 } }}
                   className="mt-20"
                 >
-                  <Form.Item label={t('pages.xray.nord.privateKey')}>
-                    <Input
-                      value={manualKey}
-                      placeholder={t('pages.xray.nord.privateKey')}
-                      onChange={(e) => setManualKey(e.target.value)}
-                    />
-                    <Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
-                      {t('save')}
-                    </Button>
-                  </Form.Item>
+                  <FormField name="manualKey" label={t('pages.xray.nord.privateKey')}>
+                    <Input placeholder={t('pages.xray.nord.privateKey')} />
+                  </FormField>
+                  <Button type="primary" className="mt-10" loading={loading} icon={<SaveOutlined />} onClick={saveKey}>
+                    {t('save')}
+                  </Button>
                 </Form>
               ),
             },
@@ -320,35 +325,34 @@ export default function NordModal({
           <Divider className="zero-margin">{t('pages.xray.warp.settings')}</Divider>
 
           <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 18 } }} className="mt-10">
-            <Form.Item label={t('pages.xray.outbound.country')}>
+            <FormField
+              name="countryId"
+              label={t('pages.xray.outbound.country')}
+              transform={{ input: (v) => v ?? undefined }}
+              onAfterChange={(v) => fetchServers(v as number)}
+            >
               <Select
-                value={countryId ?? undefined}
                 showSearch={{ optionFilterProp: 'label' }}
-                onChange={(v) => fetchServers(v)}
                 options={countries.map((c) => ({
                   value: c.id,
                   label: `${c.name} (${c.code})`,
                 }))}
               />
-            </Form.Item>
+            </FormField>
 
             {cities.length > 0 && (
-              <Form.Item label={t('pages.xray.outbound.city')}>
+              <FormField name="cityId" label={t('pages.xray.outbound.city')}>
                 <Select
-                  value={cityId}
                   showSearch={{ optionFilterProp: 'label' }}
-                  onChange={setCityId}
                   options={[{ value: null, label: t('pages.xray.outbound.allCities') }, ...cities.map((c) => ({ value: c.id, label: c.name }))]}
                 />
-              </Form.Item>
+              </FormField>
             )}
 
             {filteredServers.length > 0 && (
-              <Form.Item label={t('pages.xray.outbound.server')}>
+              <FormField name="serverId" label={t('pages.xray.outbound.server')}>
                 <Select
-                  value={serverId}
                   showSearch={{ optionFilterProp: 'label' }}
-                  onChange={setServerId}
                   options={filteredServers.map((s) => ({
                     value: s.id,
                     label: `${s.cityName} ${s.name} ${s.hostname}`,
@@ -364,7 +368,7 @@ export default function NordModal({
                     ),
                   }))}
                 />
-              </Form.Item>
+              </FormField>
             )}
           </Form>
 
@@ -392,6 +396,7 @@ export default function NordModal({
           )}
         </>
       )}
+      </FormProvider>
       </Modal>
     </>
   );

+ 51 - 43
frontend/src/pages/xray/overrides/WarpModal.tsx

@@ -12,8 +12,10 @@ import {
   Tag,
 } from 'antd';
 import { ApiOutlined, SyncOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 
 import { HttpUtil, SizeFormatter, ObjectUtil, Wireguard } from '@/utils';
+import { FormField } from '@/components/form/rhf';
 import './WarpModal.css';
 
 interface WarpModalProps {
@@ -51,6 +53,13 @@ interface WarpConfig {
   };
 }
 
+interface WarpFormValues {
+  warpPlus: string;
+  updateInterval: number;
+}
+
+const EMPTY: WarpFormValues = { warpPlus: '', updateInterval: 0 };
+
 function addressesFor(addrs: { v4?: string; v6?: string }): string[] {
   const out: string[] = [];
   if (addrs.v4) out.push(`${addrs.v4}/32`);
@@ -79,10 +88,10 @@ export default function WarpModal({
   const [loading, setLoading] = useState(false);
   const [warpData, setWarpData] = useState<WarpData | null>(null);
   const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
-  const [warpPlus, setWarpPlus] = useState('');
-  const [updateInterval, setUpdateInterval] = useState<number>(0);
   const [licenseError, setLicenseError] = useState('');
   const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
+  const methods = useForm<WarpFormValues>({ defaultValues: EMPTY });
+  const warpPlusValue = useWatch({ control: methods.control, name: 'warpPlus' }) ?? '';
 
   const warpOutboundIndex = useMemo(() => {
     const list = templateSettings?.outbounds;
@@ -132,12 +141,12 @@ export default function WarpModal({
       }
       const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
       if (settingMsg?.success && settingMsg.obj) {
-        setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
+        methods.setValue('updateInterval', Number(settingMsg.obj.warpUpdateInterval) || 0);
       }
     } finally {
       setLoading(false);
     }
-  }, []);
+  }, [methods]);
 
   useEffect(() => {
     if (!open) return;
@@ -202,7 +211,7 @@ export default function WarpModal({
   async function saveInterval() {
     setLoading(true);
     try {
-      const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
+      const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: methods.getValues('updateInterval') });
       if (msg?.success) {
         messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
       }
@@ -212,15 +221,16 @@ export default function WarpModal({
   }
 
   async function updateLicense() {
-    if (warpPlus.length < 26) return;
+    const licenseValue = methods.getValues('warpPlus');
+    if (licenseValue.length < 26) return;
     setLoading(true);
     setLicenseError('');
     try {
-      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
+      const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: licenseValue });
       if (msg?.success && msg.obj) {
         setWarpData(JSON.parse(msg.obj));
         setWarpConfig(null);
-        setWarpPlus('');
+        methods.setValue('warpPlus', '');
       } else {
         setLicenseError(msg?.msg || t('pages.xray.warp.licenseError'));
       }
@@ -266,6 +276,7 @@ export default function WarpModal({
     <>
       {messageContextHolder}
       <Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
+        <FormProvider {...methods}>
         {!hasWarp ? (
           <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
             {t('pages.xray.warp.createAccount')}
@@ -307,29 +318,26 @@ export default function WarpModal({
                   label: t('pages.xray.warp.licenseKeyLabel'),
                   children: (
                     <Form colon={false} labelCol={{ md: { span: 6 } }} wrapperCol={{ md: { span: 14 } }}>
-                      <Form.Item label={t('pages.xray.warp.key')}>
-                        <Input
-                          value={warpPlus}
-                          placeholder={t('pages.xray.warp.keyPlaceholder')}
-                          onChange={(e) => {
-                            setWarpPlus(e.target.value);
-                            setLicenseError('');
-                          }}
-                        />
-                        <div className="license-actions mt-8">
-                          <Button
-                            type="primary"
-                            disabled={warpPlus.length < 26}
-                            loading={loading}
-                            onClick={updateLicense}
-                          >
-                            {t('update')}
-                          </Button>
-                          {licenseError && (
-                            <Alert title={licenseError} type="error" showIcon className="license-error" />
-                          )}
-                        </div>
-                      </Form.Item>
+                      <FormField
+                        name="warpPlus"
+                        label={t('pages.xray.warp.key')}
+                        onAfterChange={() => setLicenseError('')}
+                      >
+                        <Input placeholder={t('pages.xray.warp.keyPlaceholder')} />
+                      </FormField>
+                      <div className="license-actions mt-8">
+                        <Button
+                          type="primary"
+                          disabled={warpPlusValue.length < 26}
+                          loading={loading}
+                          onClick={updateLicense}
+                        >
+                          {t('update')}
+                        </Button>
+                        {licenseError && (
+                          <Alert title={licenseError} type="error" showIcon className="license-error" />
+                        )}
+                      </div>
                     </Form>
                   ),
                 },
@@ -338,18 +346,17 @@ export default function WarpModal({
                   label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
                   children: (
                     <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
-                      <Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
-                        tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
-                        <Input
-                          type="number"
-                          min={0}
-                          value={updateInterval}
-                          onChange={(e) => setUpdateInterval(Number(e.target.value))}
-                        />
-                        <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
-                          {t('save', 'Save')}
-                        </Button>
-                      </Form.Item>
+                      <FormField
+                        name="updateInterval"
+                        label={t('pages.xray.warp.intervalDays', 'Interval (Days)')}
+                        tooltip={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}
+                        transform={{ output: (v) => Number(v) }}
+                      >
+                        <Input type="number" min={0} />
+                      </FormField>
+                      <Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
+                        {t('save', 'Save')}
+                      </Button>
                     </Form>
                   ),
                 },
@@ -431,6 +438,7 @@ export default function WarpModal({
             )}
           </>
         )}
+        </FormProvider>
       </Modal>
     </>
   );

+ 150 - 159
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -1,8 +1,10 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
 import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
+import { FormProvider, useForm, useWatch } from 'react-hook-form';
 import { InputAddon } from '@/components/ui';
+import { FormField } from '@/components/form/rhf';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
 import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
@@ -36,9 +38,7 @@ interface RuleFormModalProps {
   onConfirm: (rule: Record<string, unknown>) => void;
 }
 
-type FormState = RuleFormValues;
-
-const initialForm = (): FormState => ({
+const initialForm = (): RuleFormValues => ({
   enabled: true,
   domain: '',
   ip: '',
@@ -73,7 +73,7 @@ export default function RuleFormModal({
   onConfirm,
 }: RuleFormModalProps) {
   const { t } = useTranslation();
-  const [form, setForm] = useState<FormState>(initialForm);
+  const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
   const isEdit = rule != null;
 
   const { data: inboundOptions } = useInboundOptions();
@@ -82,7 +82,7 @@ export default function RuleFormModal({
   useEffect(() => {
     if (!open) return;
     if (rule) {
-      setForm({
+      methods.reset({
         enabled: rule.enabled !== false,
         domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
         ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
@@ -99,15 +99,14 @@ export default function RuleFormModal({
         balancerTag: rule.balancerTag || '',
       });
     } else {
-      setForm(initialForm());
+      methods.reset(initialForm());
     }
-  }, [open, rule]);
+  }, [open, rule, methods]);
 
-  const update = <K extends keyof FormState>(key: K, value: FormState[K]) =>
-    setForm((prev) => ({ ...prev, [key]: value }));
+  const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
 
   function submit() {
-    const validated = RuleFormSchema.safeParse(form);
+    const validated = RuleFormSchema.safeParse(methods.getValues());
     if (!validated.success) return;
     const v = validated.data;
     const built: Record<string, unknown> = {
@@ -154,167 +153,159 @@ export default function RuleFormModal({
       onOk={submit}
       onCancel={onClose}
     >
-      <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
-        <Form.Item label={t('enable')}>
-          <Switch
-            checked={form.enabled}
-            onChange={(checked) => update('enabled', checked)}
-            disabled={isApiRule(rule ?? {})}
-          />
-        </Form.Item>
+      <FormProvider {...methods}>
+        <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
+          <FormField name="enabled" label={t('enable')} valueProp="checked">
+            <Switch disabled={isApiRule(rule ?? {})} />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.sourceIP} onChange={(e) => update('sourceIP', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
-        </Form.Item>
+          <FormField
+            name="sourceIP"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.sourcePort} onChange={(e) => update('sourcePort', e.target.value)} placeholder="53,443,1000-2000" />
-        </Form.Item>
+          <FormField
+            name="sourcePort"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="53,443,1000-2000" />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.vlessRoute} onChange={(e) => update('vlessRoute', e.target.value)} placeholder="53,443,1000-2000" />
-        </Form.Item>
+          <FormField
+            name="vlessRoute"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="53,443,1000-2000" />
+          </FormField>
 
-        <Form.Item label={t('pages.inbounds.network')}>
-          <Select
-            value={form.network}
-            onChange={(v) => update('network', v)}
-            options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))}
-          />
-        </Form.Item>
+          <FormField name="network" label={t('pages.inbounds.network')}>
+            <Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
+          </FormField>
 
-        <Form.Item label={t('pages.inbounds.protocol')}>
-          <Select
-            mode="multiple"
-            value={form.protocol}
-            onChange={(v) => update('protocol', v)}
-            options={PROTOCOLS.map((p) => ({ value: p, label: p }))}
-          />
-        </Form.Item>
+          <FormField name="protocol" label={t('pages.inbounds.protocol')}>
+            <Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
+          </FormField>
 
-        <Form.Item label={t('pages.xray.ruleForm.attributes')}>
-          <Button size="small" aria-label={t('add')} icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
-        </Form.Item>
-        <Form.Item wrapperCol={{ span: 24 }}>
-          {form.attrs.map((attr, idx) => (
-            <Space.Compact key={idx} block className="mb-8">
-              <InputAddon>{`${idx + 1}`}</InputAddon>
-              <Input
-                value={attr[0]}
-                aria-label={t('pages.nodes.name')}
-                placeholder={t('pages.nodes.name')}
-                onChange={(e) => {
-                  const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
-                  update('attrs', next);
-                }}
-              />
-              <Input
-                value={attr[1]}
-                aria-label={t('pages.xray.ruleForm.value')}
-                placeholder={t('pages.xray.ruleForm.value')}
-                onChange={(e) => {
-                  const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
-                  update('attrs', next);
-                }}
-              />
-              <Button
-                aria-label={t('remove')}
-                icon={<MinusOutlined />}
-                onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
-              />
-            </Space.Compact>
-          ))}
-        </Form.Item>
+          <Form.Item label={t('pages.xray.ruleForm.attributes')}>
+            <Button
+              size="small"
+              aria-label={t('add')}
+              icon={<PlusOutlined />}
+              onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
+            />
+          </Form.Item>
+          <Form.Item wrapperCol={{ span: 24 }}>
+            {attrs.map((attr, idx) => (
+              <Space.Compact key={idx} block className="mb-8">
+                <InputAddon>{`${idx + 1}`}</InputAddon>
+                <Input
+                  value={attr[0]}
+                  aria-label={t('pages.nodes.name')}
+                  placeholder={t('pages.nodes.name')}
+                  onChange={(e) => {
+                    const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
+                    methods.setValue('attrs', next);
+                  }}
+                />
+                <Input
+                  value={attr[1]}
+                  aria-label={t('pages.xray.ruleForm.value')}
+                  placeholder={t('pages.xray.ruleForm.value')}
+                  onChange={(e) => {
+                    const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
+                    methods.setValue('attrs', next);
+                  }}
+                />
+                <Button
+                  aria-label={t('remove')}
+                  icon={<MinusOutlined />}
+                  onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
+                />
+              </Space.Compact>
+            ))}
+          </Form.Item>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              IP <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.ip} onChange={(e) => update('ip', e.target.value)} placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
-        </Form.Item>
+          <FormField
+            name="ip"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                IP <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.domain} onChange={(e) => update('domain', e.target.value)} placeholder="google.com, geosite:cn" />
-        </Form.Item>
+          <FormField
+            name="domain"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="google.com, geosite:cn" />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.user} onChange={(e) => update('user', e.target.value)} placeholder="email address" />
-        </Form.Item>
+          <FormField
+            name="user"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="email address" />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.rules.useComma')}>
-              {t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Input value={form.port} onChange={(e) => update('port', e.target.value)} placeholder="53,443,1000-2000" />
-        </Form.Item>
+          <FormField
+            name="port"
+            label={
+              <Tooltip title={t('pages.xray.rules.useComma')}>
+                {t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Input placeholder="53,443,1000-2000" />
+          </FormField>
 
-        <Form.Item label={t('pages.xray.ruleForm.inboundTags')}>
-          <Select
-            mode="multiple"
-            value={form.inboundTag}
-            onChange={(v) => update('inboundTag', v)}
-            options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
-          />
-        </Form.Item>
+          <FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
+            <Select
+              mode="multiple"
+              options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
+            />
+          </FormField>
 
-        <Form.Item label={t('pages.xray.ruleForm.outboundTag')}>
-          <Select
-            value={form.outboundTag}
-            onChange={(v) => update('outboundTag', v)}
-            options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
-          />
-        </Form.Item>
+          <FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
+            <Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
+          </FormField>
 
-        <Form.Item
-          label={
-            <Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
-              {t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
-            </Tooltip>
-          }
-        >
-          <Select
-            value={form.balancerTag}
-            onChange={(v) => update('balancerTag', v)}
-            options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))}
-          />
-        </Form.Item>
-      </Form>
+          <FormField
+            name="balancerTag"
+            label={
+              <Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
+                {t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
+              </Tooltip>
+            }
+          >
+            <Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
+          </FormField>
+        </Form>
+      </FormProvider>
     </Modal>
   );
 }

+ 60 - 0
frontend/src/test/http-init-msw.test.ts

@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { http, HttpResponse } from 'msw';
+
+import { httpRequest, setupHttp } from '@/api/http-init';
+
+import { CSRF_TOKEN } from './msw/handlers';
+import { server } from './msw/server';
+
+const ORIGIN = 'http://localhost';
+
+describe('httpRequest against the MSW-mocked network', () => {
+  beforeEach(() => {
+    vi.stubGlobal('document', { querySelector: () => null });
+    window.X_UI_BASE_PATH = ORIGIN;
+    setupHttp();
+  });
+
+  afterEach(() => {
+    vi.unstubAllGlobals();
+    delete window.X_UI_BASE_PATH;
+  });
+
+  it('fetches a CSRF token, then refetches and retries once after a 403', async () => {
+    let posts = 0;
+    const sentTokens: (string | null)[] = [];
+    server.use(
+      http.get(`${ORIGIN}/csrf-token`, () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
+      http.post(`${ORIGIN}/panel/api/test`, ({ request }) => {
+        posts += 1;
+        sentTokens.push(request.headers.get('X-CSRF-Token'));
+        if (posts === 1) return new HttpResponse(null, { status: 403 });
+        return HttpResponse.json({ success: true, obj: 'saved' });
+      }),
+    );
+
+    const res = await httpRequest('POST', '/panel/api/test', { hello: 'world' });
+
+    expect(posts).toBe(2);
+    expect(sentTokens).toEqual([CSRF_TOKEN, CSRF_TOKEN]);
+    expect(res.status).toBe(200);
+    expect(res.data).toEqual({ success: true, obj: 'saved' });
+  });
+
+  it('resolves a safe GET without requesting a CSRF token', async () => {
+    let csrfHits = 0;
+    server.use(
+      http.get(`${ORIGIN}/csrf-token`, () => {
+        csrfHits += 1;
+        return HttpResponse.json({ success: true, obj: CSRF_TOKEN });
+      }),
+      http.get(`${ORIGIN}/panel/api/status`, () => HttpResponse.json({ success: true, obj: { up: true } })),
+    );
+
+    const res = await httpRequest('GET', '/panel/api/status');
+
+    expect(csrfHits).toBe(0);
+    expect(res.status).toBe(200);
+    expect(res.data).toEqual({ success: true, obj: { up: true } });
+  });
+});

+ 29 - 27
frontend/src/test/inbound-form-blocks.test.tsx

@@ -1,6 +1,7 @@
 import { describe, it, expect } from 'vitest';
-import { Form, type FormInstance } from 'antd';
+import { Form } from 'antd';
 import type { ReactNode } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
 
 import {
   GrpcForm,
@@ -17,61 +18,62 @@ import { renderWithProviders, fieldLabels } from './test-utils';
 
 function FormHarness({
   children,
-  initialValues,
+  defaultValues,
 }: {
-  children: (form: FormInstance<InboundFormValues>) => ReactNode;
-  initialValues?: Record<string, unknown>;
+  children: ReactNode;
+  defaultValues?: Record<string, unknown>;
 }) {
-  const [form] = Form.useForm<InboundFormValues>();
-  return <Form form={form} initialValues={initialValues}>{children(form)}</Form>;
+  const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
+  return (
+    <FormProvider {...methods}>
+      <Form>{children}</Form>
+    </FormProvider>
+  );
 }
 
-function renderInForm(
-  node: (form: FormInstance<InboundFormValues>) => ReactNode,
-  initialValues?: Record<string, unknown>,
-) {
-  return renderWithProviders(<FormHarness initialValues={initialValues}>{node}</FormHarness>);
+function renderInForm(node: ReactNode, defaultValues?: Record<string, unknown>) {
+  return renderWithProviders(<FormHarness defaultValues={defaultValues}>{node}</FormHarness>);
 }
 
 const noop = () => {};
 
 describe('inbound transport forms', () => {
   it('RawForm field structure is stable', () => {
-    renderInForm(() => <RawForm />);
+    renderInForm(<RawForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('WsForm field structure is stable', () => {
-    renderInForm(() => <WsForm />);
+    renderInForm(<WsForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('GrpcForm field structure is stable', () => {
-    renderInForm(() => <GrpcForm />);
+    renderInForm(<GrpcForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('KcpForm field structure is stable', () => {
-    renderInForm(() => <KcpForm />);
+    renderInForm(<KcpForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('HttpUpgradeForm field structure is stable', () => {
-    renderInForm(() => <HttpUpgradeForm />);
+    renderInForm(<HttpUpgradeForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('XhttpForm field structure is stable', () => {
-    renderInForm((form) => <XhttpForm form={form} />);
+    renderInForm(<XhttpForm />);
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('SockoptForm field structure is stable (server-side fields only)', () => {
-    // The inbound sockopt form shows only server/listening-side fields;
-    // outbound-only fields (dialerProxy, domainStrategy, interface,
-    // addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form.
+    /* The inbound sockopt form shows only server/listening-side fields;
+       outbound-only fields (dialerProxy, domainStrategy, interface,
+       addressPortStrategy, happyEyeballs, tcpMptcp) live in the outbound form. */
     renderInForm(
-      () => <SockoptForm toggleSockopt={noop} network="tcp" />,
+      <SockoptForm toggleSockopt={noop} network="tcp" />,
       { streamSettings: { sockopt: { mark: 0 } } },
     );
     expect(fieldLabels()).toMatchSnapshot();
@@ -80,7 +82,7 @@ describe('inbound transport forms', () => {
 
 describe('inbound security forms', () => {
   it('TlsForm field structure is stable', () => {
-    renderInForm(() => (
+    renderInForm(
       <TlsForm
         saving={false}
         setCertFromPanel={noop}
@@ -89,13 +91,13 @@ describe('inbound security forms', () => {
         pinFromRemote={noop}
         getNewEchCert={noop}
         clearEchCert={noop}
-      />
-    ));
+      />,
+    );
     expect(fieldLabels()).toMatchSnapshot();
   });
 
   it('RealityForm field structure is stable', () => {
-    renderInForm(() => (
+    renderInForm(
       <RealityForm
         saving={false}
         scanning={false}
@@ -109,8 +111,8 @@ describe('inbound security forms', () => {
         clearRealityKeypair={noop}
         genMldsa65={noop}
         clearMldsa65={noop}
-      />
-    ));
+      />,
+    );
     expect(fieldLabels()).toMatchSnapshot();
   });
 });

+ 7 - 0
frontend/src/test/msw/handlers.ts

@@ -0,0 +1,7 @@
+import { http, HttpResponse } from 'msw';
+
+export const CSRF_TOKEN = 'test-csrf-token';
+
+export const handlers = [
+  http.get('*/csrf-token', () => HttpResponse.json({ success: true, obj: CSRF_TOKEN })),
+];

+ 5 - 0
frontend/src/test/msw/server.ts

@@ -0,0 +1,5 @@
+import { setupServer } from 'msw/node';
+
+import { handlers } from './handlers';
+
+export const server = setupServer(...handlers);

+ 94 - 0
frontend/src/test/rhf-form-field.test.tsx

@@ -0,0 +1,94 @@
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { FormProvider } from 'react-hook-form';
+import { Form, Input, InputNumber, Switch } from 'antd';
+import { z } from 'zod';
+
+import { FormField, useZodForm } from '@/components/form/rhf';
+
+const GB = 1024 * 1024 * 1024;
+
+const Schema = z.object({
+  email: z.string().min(1, 'email-required'),
+  enabled: z.boolean(),
+  slug: z.string(),
+  bytes: z.number(),
+});
+
+type Values = z.infer<typeof Schema>;
+
+function Harness({ onSubmit }: { onSubmit: (values: Values) => void }) {
+  const methods = useZodForm(Schema, {
+    defaultValues: { email: '', enabled: false, slug: '', bytes: 2 * GB },
+  });
+  return (
+    <FormProvider {...methods}>
+      <Form layout="vertical">
+        <FormField name="email" label="Email">
+          <Input aria-label="email" />
+        </FormField>
+        <FormField name="enabled" label="Enabled" valueProp="checked">
+          <Switch aria-label="enabled" />
+        </FormField>
+        <FormField name="slug" label="Slug" transform={{ output: (v) => String(v).toLowerCase() }}>
+          <Input aria-label="slug" />
+        </FormField>
+        <FormField
+          name="bytes"
+          label="Traffic"
+          transform={{
+            input: (v) => (typeof v === 'number' ? v / GB : v),
+            output: (v) => (typeof v === 'number' ? v * GB : 0),
+          }}
+        >
+          <InputNumber aria-label="bytes" />
+        </FormField>
+        <button type="button" onClick={methods.handleSubmit(onSubmit)}>Save</button>
+      </Form>
+    </FormProvider>
+  );
+}
+
+describe('FormField', () => {
+  it('submits values normalized from the Ant Design inputs', async () => {
+    const onSubmit = vi.fn();
+    render(<Harness onSubmit={onSubmit} />);
+
+    fireEvent.change(screen.getByLabelText('email'), { target: { value: '[email protected]' } });
+    fireEvent.click(screen.getByLabelText('enabled'));
+    fireEvent.change(screen.getByLabelText('slug'), { target: { value: 'HELLO' } });
+    fireEvent.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+    expect(onSubmit.mock.calls[0][0]).toMatchObject({
+      email: '[email protected]',
+      enabled: true,
+      slug: 'hello',
+      bytes: 2 * GB,
+    });
+  });
+
+  it('applies the transform output before storing the value', async () => {
+    const onSubmit = vi.fn();
+    render(<Harness onSubmit={onSubmit} />);
+
+    fireEvent.change(screen.getByLabelText('email'), { target: { value: 'x' } });
+    const bytes = screen.getByLabelText('bytes');
+    fireEvent.change(bytes, { target: { value: '5' } });
+    fireEvent.blur(bytes);
+    fireEvent.click(screen.getByText('Save'));
+
+    await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
+    expect(onSubmit.mock.calls[0][0].bytes).toBe(5 * GB);
+  });
+
+  it('surfaces a resolver validation error as help text and blocks submit', async () => {
+    const onSubmit = vi.fn();
+    render(<Harness onSubmit={onSubmit} />);
+
+    fireEvent.click(screen.getByText('Save'));
+
+    await screen.findByText('email-required');
+    expect(onSubmit).not.toHaveBeenCalled();
+  });
+});

+ 7 - 0
frontend/src/test/setup.msw.ts

@@ -0,0 +1,7 @@
+import { afterAll, afterEach, beforeAll } from 'vitest';
+
+import { server } from './msw/server';
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+afterEach(() => server.resetHandlers());
+afterAll(() => server.close());

+ 0 - 15
frontend/src/utils/zodForm.ts

@@ -1,15 +0,0 @@
-import type { Rule } from 'antd/es/form';
-import type { TFunction } from 'i18next';
-import type { z } from 'zod';
-
-export function antdRule<T extends z.ZodType>(schema: T, t: TFunction): Rule {
-  return {
-    validator: async (_rule, value) => {
-      const result = schema.safeParse(value);
-      if (result.success) return;
-      const issue = result.error.issues[0];
-      const key = issue?.message ?? 'validation.invalid';
-      throw new Error(t(key, { defaultValue: key }));
-    },
-  };
-}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików