HostFormModal.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  1. import { useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
  4. import {
  5. ProfileOutlined,
  6. SafetyCertificateOutlined,
  7. ControlOutlined,
  8. NodeIndexOutlined,
  9. SettingOutlined,
  10. PartitionOutlined,
  11. DeploymentUnitOutlined,
  12. RocketOutlined,
  13. } from '@ant-design/icons';
  14. import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
  15. import type { HostRecord } from '@/api/queries/useHostsQuery';
  16. import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
  17. import type { InboundOption } from '@/schemas/client';
  18. import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
  19. import { FormField, rhfZodValidate } from '@/components/form/rhf';
  20. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  21. import { useMediaQuery } from '@/hooks/useMediaQuery';
  22. import { catTabLabel } from '@/pages/settings/catTabLabel';
  23. import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
  24. type FormShape = Omit<BulkAddHostValues, 'isDisabled'> & {
  25. enable: boolean;
  26. };
  27. interface HostFormModalProps {
  28. open: boolean;
  29. mode: 'add' | 'edit';
  30. host: HostRecord | null;
  31. inboundOptions: InboundOption[];
  32. existingHosts: HostRecord[];
  33. save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>;
  34. onOpenChange: (open: boolean) => void;
  35. }
  36. const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
  37. function defaultsFor(host: HostRecord | null): FormShape {
  38. return {
  39. inboundIds: host?.inboundIds ?? [],
  40. hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
  41. sortOrder: host?.sortOrder ?? 0,
  42. remark: host?.remark ?? '',
  43. serverDescription: host?.serverDescription ?? '',
  44. enable: host ? !host.isDisabled : true,
  45. isHidden: host?.isHidden ?? false,
  46. tags: host?.tags ?? [],
  47. port: host?.port ?? 0,
  48. security: (host?.security as BulkAddHostValues['security']) ?? 'same',
  49. sni: host?.sni ?? '',
  50. hostHeader: host?.hostHeader ?? '',
  51. path: host?.path ?? '',
  52. alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
  53. fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
  54. overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
  55. keepSniBlank: host?.keepSniBlank ?? false,
  56. pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
  57. verifyPeerCertByName: (host?.verifyPeerCertByName as string | undefined) ?? '',
  58. allowInsecure: host?.allowInsecure ?? false,
  59. echConfigList: host?.echConfigList ?? '',
  60. muxParams: asString(host?.muxParams),
  61. sockoptParams: asString(host?.sockoptParams),
  62. finalMask: host?.finalMask ?? '',
  63. vlessRoute: host?.vlessRoute ?? '',
  64. excludeFromSubTypes:
  65. (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
  66. nodeGuids: host?.nodeGuids ?? [],
  67. mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
  68. mihomoX25519: host?.mihomoX25519 ?? false,
  69. shuffleHost: host?.shuffleHost ?? false,
  70. };
  71. }
  72. export default function HostFormModal({
  73. open,
  74. mode,
  75. host,
  76. inboundOptions,
  77. existingHosts,
  78. save,
  79. onOpenChange,
  80. }: HostFormModalProps) {
  81. const { t } = useTranslation();
  82. const { isMobile } = useMediaQuery();
  83. const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
  84. const [messageApi, messageContextHolder] = message.useMessage();
  85. const [loading, setLoading] = useState(false);
  86. const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
  87. const showTls = security === 'tls' || security === 'reality' || security === 'same';
  88. const showTlsExtras = security === 'tls' || security === 'same';
  89. // React resets this during render rather than in an effect so the modal's
  90. // first open frame already shows cleared fields.
  91. const openHost = open ? host : null;
  92. const [syncedHost, setSyncedHost] = useState(openHost);
  93. if (openHost !== syncedHost) {
  94. setSyncedHost(openHost);
  95. if (open) {
  96. methods.reset(defaultsFor(host));
  97. setLoading(false);
  98. }
  99. }
  100. const { nodes } = useNodesQuery();
  101. const inboundSelectOptions = useMemo(
  102. () =>
  103. inboundOptions.map((ib) => ({
  104. value: ib.id,
  105. label: ib.remark || ib.tag || `#${ib.id}`,
  106. })),
  107. [inboundOptions],
  108. );
  109. const nodeSelectOptions = useMemo(
  110. () =>
  111. nodes
  112. .filter((n) => n.guid)
  113. .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
  114. [nodes],
  115. );
  116. const alpnOptions = useMemo(
  117. () => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })),
  118. [],
  119. );
  120. const fpOptions = useMemo(
  121. // '' = None first: Hysteria (and any no-uTLS host) must be selectable.
  122. () => [
  123. { value: '', label: t('none') },
  124. ...Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
  125. ],
  126. [t],
  127. );
  128. const hostOptions = useMemo(() => {
  129. const addresses = new Set<string>();
  130. for (const h of existingHosts || []) {
  131. if (h.hosts) {
  132. for (const addr of h.hosts) {
  133. if (addr && addr.trim() !== '') {
  134. addresses.add(addr);
  135. }
  136. }
  137. }
  138. }
  139. return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
  140. }, [existingHosts]);
  141. const onFinish = async (values: FormShape) => {
  142. if (loading) return;
  143. const { enable, ...rest } = values;
  144. const isDisabled = !enable;
  145. const payload: BulkAddHostValues = {
  146. ...rest,
  147. hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
  148. isDisabled,
  149. };
  150. setLoading(true);
  151. try {
  152. const res = await save(payload);
  153. if (res?.success) {
  154. messageApi.success(
  155. t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'),
  156. );
  157. onOpenChange(false);
  158. } else if (res?.msg) {
  159. messageApi.error(res.msg);
  160. }
  161. } catch (err) {
  162. console.error(err);
  163. } finally {
  164. setLoading(false);
  165. }
  166. };
  167. return (
  168. <Modal
  169. open={open}
  170. title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
  171. onOk={methods.handleSubmit(onFinish)}
  172. onCancel={() => onOpenChange(false)}
  173. confirmLoading={loading}
  174. okText={t('save')}
  175. cancelText={t('cancel')}
  176. destroyOnHidden
  177. width={isMobile ? '95vw' : 760}
  178. styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
  179. >
  180. {messageContextHolder}
  181. <FormProvider {...methods}>
  182. <Form
  183. colon={false}
  184. labelCol={{ sm: { span: 8 } }}
  185. wrapperCol={{ sm: { span: 14 } }}
  186. labelWrap
  187. >
  188. <Tabs
  189. defaultActiveKey="basic"
  190. items={[
  191. {
  192. key: 'basic',
  193. forceRender: true,
  194. label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
  195. children: (
  196. <>
  197. <FormField
  198. name="remark"
  199. label={t('pages.hosts.fields.remark')}
  200. tooltip={t('pages.hosts.hints.remark')}
  201. rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}
  202. >
  203. <Input maxLength={256} />
  204. </FormField>
  205. <FormField
  206. name="serverDescription"
  207. label={t('pages.hosts.fields.serverDescription')}
  208. tooltip={t('pages.hosts.hints.serverDescription')}
  209. >
  210. <Input maxLength={64} />
  211. </FormField>
  212. <FormField
  213. name="inboundIds"
  214. label={t('pages.hosts.fields.inbound')}
  215. rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}
  216. >
  217. <Select
  218. mode="multiple"
  219. options={inboundSelectOptions}
  220. showSearch={{ optionFilterProp: 'label' }}
  221. placeholder={t('pages.hosts.selectInbound')}
  222. />
  223. </FormField>
  224. <FormField
  225. name="hosts"
  226. label={t('pages.hosts.fields.address')}
  227. tooltip={t('pages.hosts.hints.address')}
  228. rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}
  229. >
  230. <Select
  231. mode="tags"
  232. options={hostOptions}
  233. tokenSeparators={[',', ';', ' ']}
  234. placeholder="cdn.example.com, cdn2.example.com:443"
  235. />
  236. </FormField>
  237. <FormField
  238. name="port"
  239. label={t('pages.hosts.fields.port')}
  240. tooltip={t('pages.hosts.hints.port')}
  241. >
  242. <InputNumber min={0} max={65535} />
  243. </FormField>
  244. <FormField
  245. name="tags"
  246. label={t('pages.hosts.fields.tags')}
  247. tooltip={t('pages.hosts.hints.tags')}
  248. >
  249. <Select mode="tags" allowClear tokenSeparators={[',']} />
  250. </FormField>
  251. <FormField
  252. name="nodeGuids"
  253. label={t('pages.hosts.fields.nodeGuids')}
  254. tooltip={t('pages.hosts.hints.nodeGuids')}
  255. >
  256. <Select
  257. mode="multiple"
  258. allowClear
  259. options={nodeSelectOptions}
  260. showSearch={{ optionFilterProp: 'label' }}
  261. />
  262. </FormField>
  263. <FormField
  264. name="enable"
  265. label={t('pages.hosts.fields.enable')}
  266. valueProp="checked"
  267. >
  268. <Switch />
  269. </FormField>
  270. </>
  271. ),
  272. },
  273. {
  274. key: 'security',
  275. forceRender: true,
  276. label: catTabLabel(
  277. <SafetyCertificateOutlined />,
  278. t('pages.hosts.sections.security'),
  279. isMobile,
  280. ),
  281. children: (
  282. <>
  283. <FormField name="security" label={t('pages.hosts.fields.security')}>
  284. <Select
  285. options={['same', 'tls', 'none', 'reality'].map((v) => ({
  286. value: v,
  287. label: v,
  288. }))}
  289. />
  290. </FormField>
  291. {showTls && (
  292. <>
  293. <FormField name="sni" label={t('pages.hosts.fields.sni')}>
  294. <Input />
  295. </FormField>
  296. <FormField
  297. name="overrideSniFromAddress"
  298. label={t('pages.hosts.fields.overrideSniFromAddress')}
  299. valueProp="checked"
  300. >
  301. <Switch />
  302. </FormField>
  303. <FormField
  304. name="keepSniBlank"
  305. label={t('pages.hosts.fields.keepSniBlank')}
  306. valueProp="checked"
  307. >
  308. <Switch />
  309. </FormField>
  310. <FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
  311. <Select allowClear options={fpOptions} />
  312. </FormField>
  313. </>
  314. )}
  315. {showTlsExtras && (
  316. <>
  317. <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
  318. <Select mode="multiple" allowClear options={alpnOptions} />
  319. </FormField>
  320. <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
  321. <Select mode="tags" allowClear tokenSeparators={[',']} />
  322. </FormField>
  323. <FormField
  324. name="verifyPeerCertByName"
  325. label={t('pages.hosts.fields.verifyPeerCertByName')}
  326. tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
  327. >
  328. <Input placeholder="example.com" />
  329. </FormField>
  330. <FormField
  331. name="allowInsecure"
  332. label={t('pages.hosts.fields.allowInsecure')}
  333. tooltip={t('pages.hosts.hints.allowInsecure')}
  334. valueProp="checked"
  335. >
  336. <Switch />
  337. </FormField>
  338. <FormField
  339. name="echConfigList"
  340. label={t('pages.hosts.fields.echConfigList')}
  341. >
  342. <Input.TextArea rows={2} />
  343. </FormField>
  344. </>
  345. )}
  346. </>
  347. ),
  348. },
  349. {
  350. key: 'advanced',
  351. forceRender: true,
  352. label: catTabLabel(
  353. <ControlOutlined />,
  354. t('pages.hosts.sections.advanced'),
  355. isMobile,
  356. ),
  357. children: (
  358. <Tabs
  359. size="small"
  360. defaultActiveKey="adv-general"
  361. items={[
  362. {
  363. key: 'adv-general',
  364. forceRender: true,
  365. label: catTabLabel(
  366. <SettingOutlined />,
  367. t('pages.hosts.sections.general'),
  368. isMobile,
  369. ),
  370. children: (
  371. <>
  372. <FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
  373. <Input />
  374. </FormField>
  375. <FormField name="path" label={t('pages.hosts.fields.path')}>
  376. <Input />
  377. </FormField>
  378. <FormField
  379. name="vlessRoute"
  380. label={t('pages.hosts.fields.vlessRoute')}
  381. tooltip={t('pages.hosts.hints.vlessRoute')}
  382. >
  383. <Input placeholder="443" />
  384. </FormField>
  385. <FormField
  386. name="excludeFromSubTypes"
  387. label={t('pages.hosts.fields.excludeFromSubTypes')}
  388. >
  389. <Select
  390. mode="multiple"
  391. allowClear
  392. options={['raw', 'json', 'clash'].map((v) => ({
  393. value: v,
  394. label: v,
  395. }))}
  396. />
  397. </FormField>
  398. </>
  399. ),
  400. },
  401. {
  402. key: 'adv-mux',
  403. forceRender: true,
  404. label: catTabLabel(
  405. <PartitionOutlined />,
  406. t('pages.hosts.fields.muxParams'),
  407. isMobile,
  408. ),
  409. children: (
  410. <Form.Item noStyle>
  411. <Controller
  412. control={methods.control}
  413. name="muxParams"
  414. render={({ field }) => (
  415. <HostMuxForm value={field.value} onChange={field.onChange} />
  416. )}
  417. />
  418. </Form.Item>
  419. ),
  420. },
  421. {
  422. key: 'adv-sockopt',
  423. forceRender: true,
  424. label: catTabLabel(
  425. <DeploymentUnitOutlined />,
  426. t('pages.hosts.fields.sockoptParams'),
  427. isMobile,
  428. ),
  429. children: (
  430. <Form.Item noStyle>
  431. <Controller
  432. control={methods.control}
  433. name="sockoptParams"
  434. render={({ field }) => (
  435. <HostSockoptForm value={field.value} onChange={field.onChange} />
  436. )}
  437. />
  438. </Form.Item>
  439. ),
  440. },
  441. {
  442. key: 'adv-finalmask',
  443. forceRender: true,
  444. label: catTabLabel(
  445. <RocketOutlined />,
  446. t('pages.hosts.fields.finalMask'),
  447. isMobile,
  448. ),
  449. children: (
  450. <Form.Item noStyle>
  451. <Controller
  452. control={methods.control}
  453. name="finalMask"
  454. render={({ field }) => (
  455. <HostFinalMaskForm value={field.value} onChange={field.onChange} />
  456. )}
  457. />
  458. </Form.Item>
  459. ),
  460. },
  461. ]}
  462. />
  463. ),
  464. },
  465. {
  466. key: 'clash',
  467. forceRender: true,
  468. label: catTabLabel(
  469. <NodeIndexOutlined />,
  470. t('pages.hosts.sections.clash'),
  471. isMobile,
  472. ),
  473. children: (
  474. <>
  475. <FormField
  476. name="mihomoIpVersion"
  477. label={t('pages.hosts.fields.mihomoIpVersion')}
  478. >
  479. <Select
  480. allowClear
  481. options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map(
  482. (v) => ({ value: v, label: v }),
  483. )}
  484. />
  485. </FormField>
  486. <FormField
  487. name="mihomoX25519"
  488. label={t('pages.hosts.fields.mihomoX25519')}
  489. valueProp="checked"
  490. >
  491. <Switch />
  492. </FormField>
  493. <FormField
  494. name="shuffleHost"
  495. label={t('pages.hosts.fields.shuffleHost')}
  496. valueProp="checked"
  497. >
  498. <Switch />
  499. </FormField>
  500. </>
  501. ),
  502. },
  503. ]}
  504. />
  505. </Form>
  506. </FormProvider>
  507. </Modal>
  508. );
  509. }