HostFormModal.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. import { useEffect, useMemo } 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 type { HostRecord } from '@/api/queries/useHostsQuery';
  15. import type { HostFormValues } from '@/schemas/api/host';
  16. import type { InboundOption } from '@/schemas/client';
  17. import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
  18. import { useNodesQuery } from '@/api/queries/useNodesQuery';
  19. import { useMediaQuery } from '@/hooks/useMediaQuery';
  20. import { catTabLabel } from '@/pages/settings/catTabLabel';
  21. import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
  22. // inboundId is optional in the form so a new host starts unselected (the Select
  23. // shows its placeholder instead of 0); the required rule enforces it on submit.
  24. type FormShape = Omit<HostFormValues, 'isDisabled' | 'inboundId'> & { enable: boolean; inboundId?: number };
  25. interface HostFormModalProps {
  26. open: boolean;
  27. mode: 'add' | 'edit';
  28. host: HostRecord | null;
  29. inboundOptions: InboundOption[];
  30. save: (payload: Partial<HostFormValues>) => Promise<{ success?: boolean; msg?: string } | undefined>;
  31. onOpenChange: (open: boolean) => void;
  32. }
  33. const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
  34. function defaultsFor(host: HostRecord | null): FormShape {
  35. return {
  36. inboundId: host?.inboundId,
  37. sortOrder: host?.sortOrder ?? 0,
  38. remark: host?.remark ?? '',
  39. serverDescription: host?.serverDescription ?? '',
  40. enable: host ? !host.isDisabled : true,
  41. isHidden: host?.isHidden ?? false,
  42. tags: host?.tags ?? [],
  43. address: host?.address ?? '',
  44. port: host?.port ?? 0,
  45. security: (host?.security as HostFormValues['security']) ?? 'same',
  46. sni: host?.sni ?? '',
  47. hostHeader: host?.hostHeader ?? '',
  48. path: host?.path ?? '',
  49. alpn: (host?.alpn as HostFormValues['alpn']) ?? [],
  50. fingerprint: host?.fingerprint as HostFormValues['fingerprint'],
  51. overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
  52. keepSniBlank: host?.keepSniBlank ?? false,
  53. pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
  54. verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
  55. allowInsecure: host?.allowInsecure ?? false,
  56. echConfigList: host?.echConfigList ?? '',
  57. muxParams: asString(host?.muxParams),
  58. sockoptParams: asString(host?.sockoptParams),
  59. finalMask: host?.finalMask ?? '',
  60. vlessRoute: host?.vlessRoute ?? '',
  61. excludeFromSubTypes: (host?.excludeFromSubTypes as HostFormValues['excludeFromSubTypes']) ?? [],
  62. nodeGuids: host?.nodeGuids ?? [],
  63. mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'],
  64. mihomoX25519: host?.mihomoX25519 ?? false,
  65. shuffleHost: host?.shuffleHost ?? false,
  66. };
  67. }
  68. export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) {
  69. const { t } = useTranslation();
  70. const { isMobile } = useMediaQuery();
  71. const [form] = Form.useForm<FormShape>();
  72. // Drive conditional field visibility off the selected security, like the
  73. // legacy externalProxy form: same/none inherit fully and hide every TLS/cert
  74. // field; reality shows only the reality-relevant subset (its keys are
  75. // inherited from the inbound); tls shows the full TLS override set.
  76. const security = (Form.useWatch('security', form) ?? 'same') as string;
  77. const showTls = security === 'tls' || security === 'reality';
  78. const showTlsExtras = security === 'tls';
  79. useEffect(() => {
  80. if (open) form.setFieldsValue(defaultsFor(host));
  81. }, [open, host, form]);
  82. const { nodes } = useNodesQuery();
  83. const inboundSelectOptions = useMemo(
  84. () => inboundOptions.map((ib) => ({
  85. value: ib.id,
  86. label: ib.remark || ib.tag || `#${ib.id}`,
  87. })),
  88. [inboundOptions],
  89. );
  90. const nodeSelectOptions = useMemo(
  91. () => nodes
  92. .filter((n) => n.guid)
  93. .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
  94. [nodes],
  95. );
  96. const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
  97. const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
  98. const onOk = async () => {
  99. let values: FormShape;
  100. try {
  101. values = await form.validateFields();
  102. } catch {
  103. return;
  104. }
  105. const { enable, ...rest } = values;
  106. const payload: Partial<HostFormValues> = { ...rest, isDisabled: !enable };
  107. const res = await save(payload);
  108. if (res?.success) {
  109. message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
  110. onOpenChange(false);
  111. } else if (res?.msg) {
  112. message.error(res.msg);
  113. }
  114. };
  115. return (
  116. <Modal
  117. open={open}
  118. title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
  119. onOk={onOk}
  120. onCancel={() => onOpenChange(false)}
  121. okText={t('save')}
  122. cancelText={t('cancel')}
  123. destroyOnHidden
  124. width={isMobile ? '95vw' : 760}
  125. styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
  126. >
  127. <Form
  128. form={form}
  129. colon={false}
  130. labelCol={{ sm: { span: 8 } }}
  131. wrapperCol={{ sm: { span: 14 } }}
  132. labelWrap
  133. initialValues={defaultsFor(host)}
  134. preserve={false}
  135. >
  136. <Tabs
  137. defaultActiveKey="basic"
  138. items={[
  139. {
  140. key: 'basic',
  141. forceRender: true,
  142. label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
  143. children: (
  144. <>
  145. <Form.Item name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={[{ required: true, max: 256 }]}>
  146. <Input maxLength={256} />
  147. </Form.Item>
  148. <Form.Item name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
  149. <Input maxLength={64} />
  150. </Form.Item>
  151. <Form.Item name="inboundId" label={t('pages.hosts.fields.inbound')} rules={[{ required: true }]}>
  152. <Select
  153. options={inboundSelectOptions}
  154. showSearch
  155. optionFilterProp="label"
  156. disabled={mode === 'edit'}
  157. placeholder={t('pages.hosts.selectInbound')}
  158. />
  159. </Form.Item>
  160. <Form.Item name="address" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')}>
  161. <Input placeholder="cdn.example.com" />
  162. </Form.Item>
  163. <Form.Item name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
  164. <InputNumber min={0} max={65535} />
  165. </Form.Item>
  166. <Form.Item name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
  167. <Select mode="tags" allowClear tokenSeparators={[',']} />
  168. </Form.Item>
  169. <Form.Item name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
  170. <Select mode="multiple" allowClear options={nodeSelectOptions} optionFilterProp="label" />
  171. </Form.Item>
  172. <Form.Item name="enable" label={t('pages.hosts.fields.enable')} valuePropName="checked">
  173. <Switch />
  174. </Form.Item>
  175. </>
  176. ),
  177. },
  178. {
  179. key: 'security',
  180. forceRender: true,
  181. label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
  182. children: (
  183. <>
  184. <Form.Item name="security" label={t('pages.hosts.fields.security')}>
  185. <Select
  186. options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
  187. />
  188. </Form.Item>
  189. {showTls && (
  190. <>
  191. <Form.Item name="sni" label={t('pages.hosts.fields.sni')}>
  192. <Input />
  193. </Form.Item>
  194. <Form.Item name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valuePropName="checked">
  195. <Switch />
  196. </Form.Item>
  197. <Form.Item name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valuePropName="checked">
  198. <Switch />
  199. </Form.Item>
  200. <Form.Item name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
  201. <Select allowClear options={fpOptions} />
  202. </Form.Item>
  203. </>
  204. )}
  205. {showTlsExtras && (
  206. <>
  207. <Form.Item name="alpn" label={t('pages.hosts.fields.alpn')}>
  208. <Select mode="multiple" allowClear options={alpnOptions} />
  209. </Form.Item>
  210. <Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
  211. <Select mode="tags" allowClear tokenSeparators={[',']} />
  212. </Form.Item>
  213. <Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
  214. <Switch />
  215. </Form.Item>
  216. <Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
  217. <Switch />
  218. </Form.Item>
  219. <Form.Item name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
  220. <Input.TextArea rows={2} />
  221. </Form.Item>
  222. </>
  223. )}
  224. </>
  225. ),
  226. },
  227. {
  228. key: 'advanced',
  229. forceRender: true,
  230. label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
  231. children: (
  232. <Tabs
  233. size="small"
  234. defaultActiveKey="adv-general"
  235. items={[
  236. {
  237. key: 'adv-general',
  238. forceRender: true,
  239. label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
  240. children: (
  241. <>
  242. <Form.Item name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
  243. <Input />
  244. </Form.Item>
  245. <Form.Item name="path" label={t('pages.hosts.fields.path')}>
  246. <Input />
  247. </Form.Item>
  248. <Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
  249. <Input placeholder="53,443,1000-2000" />
  250. </Form.Item>
  251. <Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
  252. <Select
  253. mode="multiple"
  254. allowClear
  255. options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
  256. />
  257. </Form.Item>
  258. </>
  259. ),
  260. },
  261. {
  262. key: 'adv-mux',
  263. forceRender: true,
  264. label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
  265. children: (
  266. <Form.Item name="muxParams" noStyle>
  267. <HostMuxForm />
  268. </Form.Item>
  269. ),
  270. },
  271. {
  272. key: 'adv-sockopt',
  273. forceRender: true,
  274. label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
  275. children: (
  276. <Form.Item name="sockoptParams" noStyle>
  277. <HostSockoptForm />
  278. </Form.Item>
  279. ),
  280. },
  281. {
  282. key: 'adv-finalmask',
  283. forceRender: true,
  284. label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
  285. children: (
  286. <Form.Item name="finalMask" noStyle>
  287. <HostFinalMaskForm />
  288. </Form.Item>
  289. ),
  290. },
  291. ]}
  292. />
  293. ),
  294. },
  295. {
  296. key: 'clash',
  297. forceRender: true,
  298. label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
  299. children: (
  300. <>
  301. <Form.Item name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
  302. <Select
  303. allowClear
  304. options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
  305. />
  306. </Form.Item>
  307. <Form.Item name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valuePropName="checked">
  308. <Switch />
  309. </Form.Item>
  310. <Form.Item name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valuePropName="checked">
  311. <Switch />
  312. </Form.Item>
  313. </>
  314. ),
  315. },
  316. ]}
  317. />
  318. </Form>
  319. </Modal>
  320. );
  321. }