1
0

HostFormModal.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { useEffect, 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: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
  65. nodeGuids: host?.nodeGuids ?? [],
  66. mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
  67. mihomoX25519: host?.mihomoX25519 ?? false,
  68. shuffleHost: host?.shuffleHost ?? false,
  69. };
  70. }
  71. export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) {
  72. const { t } = useTranslation();
  73. const { isMobile } = useMediaQuery();
  74. const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
  75. const [messageApi, messageContextHolder] = message.useMessage();
  76. const [loading, setLoading] = useState(false);
  77. const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
  78. const showTls = security === 'tls' || security === 'reality';
  79. const showTlsExtras = security === 'tls';
  80. useEffect(() => {
  81. if (open) {
  82. methods.reset(defaultsFor(host));
  83. setLoading(false);
  84. }
  85. }, [open, host, methods]);
  86. const { nodes } = useNodesQuery();
  87. const inboundSelectOptions = useMemo(
  88. () => inboundOptions.map((ib) => ({
  89. value: ib.id,
  90. label: ib.remark || ib.tag || `#${ib.id}`,
  91. })),
  92. [inboundOptions],
  93. );
  94. const nodeSelectOptions = useMemo(
  95. () => nodes
  96. .filter((n) => n.guid)
  97. .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
  98. [nodes],
  99. );
  100. const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
  101. const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
  102. const hostOptions = useMemo(() => {
  103. const addresses = new Set<string>();
  104. for (const h of existingHosts || []) {
  105. if (h.hosts) {
  106. for (const addr of h.hosts) {
  107. if (addr && addr.trim() !== '') {
  108. addresses.add(addr);
  109. }
  110. }
  111. }
  112. }
  113. return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
  114. }, [existingHosts]);
  115. const onFinish = async (values: FormShape) => {
  116. if (loading) return;
  117. const { enable, ...rest } = values;
  118. const isDisabled = !enable;
  119. const payload: BulkAddHostValues = {
  120. ...rest,
  121. hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
  122. isDisabled,
  123. };
  124. setLoading(true);
  125. try {
  126. const res = await save(payload);
  127. if (res?.success) {
  128. messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
  129. onOpenChange(false);
  130. } else if (res?.msg) {
  131. messageApi.error(res.msg);
  132. }
  133. } catch (err) {
  134. console.error(err);
  135. } finally {
  136. setLoading(false);
  137. }
  138. };
  139. return (
  140. <Modal
  141. open={open}
  142. title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
  143. onOk={methods.handleSubmit(onFinish)}
  144. onCancel={() => onOpenChange(false)}
  145. confirmLoading={loading}
  146. okText={t('save')}
  147. cancelText={t('cancel')}
  148. destroyOnHidden
  149. width={isMobile ? '95vw' : 760}
  150. styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
  151. >
  152. {messageContextHolder}
  153. <FormProvider {...methods}>
  154. <Form
  155. colon={false}
  156. labelCol={{ sm: { span: 8 } }}
  157. wrapperCol={{ sm: { span: 14 } }}
  158. labelWrap
  159. >
  160. <Tabs
  161. defaultActiveKey="basic"
  162. items={[
  163. {
  164. key: 'basic',
  165. forceRender: true,
  166. label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
  167. children: (
  168. <>
  169. <FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}>
  170. <Input maxLength={256} />
  171. </FormField>
  172. <FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
  173. <Input maxLength={64} />
  174. </FormField>
  175. <FormField name="inboundIds" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}>
  176. <Select
  177. mode="multiple"
  178. options={inboundSelectOptions}
  179. showSearch={{ optionFilterProp: 'label' }}
  180. placeholder={t('pages.hosts.selectInbound')}
  181. />
  182. </FormField>
  183. <FormField name="hosts" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}>
  184. <Select
  185. mode="tags"
  186. options={hostOptions}
  187. tokenSeparators={[',', ';', ' ']}
  188. placeholder="cdn.example.com, cdn2.example.com:443"
  189. />
  190. </FormField>
  191. <FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
  192. <InputNumber min={0} max={65535} />
  193. </FormField>
  194. <FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
  195. <Select mode="tags" allowClear tokenSeparators={[',']} />
  196. </FormField>
  197. <FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
  198. <Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
  199. </FormField>
  200. <FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
  201. <Switch />
  202. </FormField>
  203. </>
  204. ),
  205. },
  206. {
  207. key: 'security',
  208. forceRender: true,
  209. label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
  210. children: (
  211. <>
  212. <FormField name="security" label={t('pages.hosts.fields.security')}>
  213. <Select
  214. options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
  215. />
  216. </FormField>
  217. {showTls && (
  218. <>
  219. <FormField name="sni" label={t('pages.hosts.fields.sni')}>
  220. <Input />
  221. </FormField>
  222. <FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
  223. <Switch />
  224. </FormField>
  225. <FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
  226. <Switch />
  227. </FormField>
  228. <FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
  229. <Select allowClear options={fpOptions} />
  230. </FormField>
  231. </>
  232. )}
  233. {showTlsExtras && (
  234. <>
  235. <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
  236. <Select mode="multiple" allowClear options={alpnOptions} />
  237. </FormField>
  238. <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
  239. <Select mode="tags" allowClear tokenSeparators={[',']} />
  240. </FormField>
  241. <FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
  242. <Input placeholder="example.com" />
  243. </FormField>
  244. <FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
  245. <Switch />
  246. </FormField>
  247. <FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
  248. <Input.TextArea rows={2} />
  249. </FormField>
  250. </>
  251. )}
  252. </>
  253. ),
  254. },
  255. {
  256. key: 'advanced',
  257. forceRender: true,
  258. label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
  259. children: (
  260. <Tabs
  261. size="small"
  262. defaultActiveKey="adv-general"
  263. items={[
  264. {
  265. key: 'adv-general',
  266. forceRender: true,
  267. label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
  268. children: (
  269. <>
  270. <FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
  271. <Input />
  272. </FormField>
  273. <FormField name="path" label={t('pages.hosts.fields.path')}>
  274. <Input />
  275. </FormField>
  276. <FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
  277. <Input placeholder="443" />
  278. </FormField>
  279. <FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
  280. <Select
  281. mode="multiple"
  282. allowClear
  283. options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
  284. />
  285. </FormField>
  286. </>
  287. ),
  288. },
  289. {
  290. key: 'adv-mux',
  291. forceRender: true,
  292. label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
  293. children: (
  294. <Form.Item noStyle>
  295. <Controller
  296. control={methods.control}
  297. name="muxParams"
  298. render={({ field }) => (
  299. <HostMuxForm value={field.value} onChange={field.onChange} />
  300. )}
  301. />
  302. </Form.Item>
  303. ),
  304. },
  305. {
  306. key: 'adv-sockopt',
  307. forceRender: true,
  308. label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
  309. children: (
  310. <Form.Item noStyle>
  311. <Controller
  312. control={methods.control}
  313. name="sockoptParams"
  314. render={({ field }) => (
  315. <HostSockoptForm value={field.value} onChange={field.onChange} />
  316. )}
  317. />
  318. </Form.Item>
  319. ),
  320. },
  321. {
  322. key: 'adv-finalmask',
  323. forceRender: true,
  324. label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
  325. children: (
  326. <Form.Item noStyle>
  327. <Controller
  328. control={methods.control}
  329. name="finalMask"
  330. render={({ field }) => (
  331. <HostFinalMaskForm value={field.value} onChange={field.onChange} />
  332. )}
  333. />
  334. </Form.Item>
  335. ),
  336. },
  337. ]}
  338. />
  339. ),
  340. },
  341. {
  342. key: 'clash',
  343. forceRender: true,
  344. label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
  345. children: (
  346. <>
  347. <FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
  348. <Select
  349. allowClear
  350. options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
  351. />
  352. </FormField>
  353. <FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
  354. <Switch />
  355. </FormField>
  356. <FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
  357. <Switch />
  358. </FormField>
  359. </>
  360. ),
  361. },
  362. ]}
  363. />
  364. </Form>
  365. </FormProvider>
  366. </Modal>
  367. );
  368. }