NodeFormModal.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. import { useEffect, useMemo, useState } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Alert,
  5. Button,
  6. Col,
  7. Form,
  8. Input,
  9. InputNumber,
  10. Modal,
  11. Row,
  12. Select,
  13. Switch,
  14. message,
  15. } from 'antd';
  16. import type { NodeRecord } from '@/api/queries/useNodesQuery';
  17. import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
  18. import type { Msg } from '@/utils';
  19. import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
  20. import { antdRule } from '@/utils/zodForm';
  21. import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
  22. import './NodeFormModal.css';
  23. type Mode = 'add' | 'edit';
  24. interface NodeFormModalProps {
  25. open: boolean;
  26. mode: Mode;
  27. node: NodeRecord | null;
  28. testConnection: (payload: Partial<NodeRecord>) => Promise<Msg<ProbeResult>>;
  29. fetchFingerprint: (payload: Partial<NodeRecord>) => Promise<Msg<string>>;
  30. fetchInbounds: (payload: Partial<NodeRecord>) => Promise<Msg<RemoteInboundOption[]>>;
  31. save: (payload: Partial<NodeRecord>) => Promise<Msg<unknown>>;
  32. onOpenChange: (open: boolean) => void;
  33. }
  34. function defaultValues(): NodeFormValues {
  35. return {
  36. id: 0,
  37. name: '',
  38. remark: '',
  39. scheme: 'https',
  40. address: '',
  41. port: 2053,
  42. basePath: '/',
  43. apiToken: '',
  44. enable: true,
  45. allowPrivateAddress: false,
  46. tlsVerifyMode: 'verify',
  47. pinnedCertSha256: '',
  48. inboundSyncMode: 'all',
  49. inboundTags: [],
  50. outboundTag: '',
  51. };
  52. }
  53. export default function NodeFormModal({
  54. open,
  55. mode,
  56. node,
  57. testConnection,
  58. fetchFingerprint,
  59. fetchInbounds,
  60. save,
  61. onOpenChange,
  62. }: NodeFormModalProps) {
  63. const { t } = useTranslation();
  64. const [form] = Form.useForm<NodeFormValues>();
  65. const [messageApi, messageContextHolder] = message.useMessage();
  66. const [submitting, setSubmitting] = useState(false);
  67. const [testing, setTesting] = useState(false);
  68. const [fetchingPin, setFetchingPin] = useState(false);
  69. const [fetchingInbounds, setFetchingInbounds] = useState(false);
  70. const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
  71. const [testResult, setTestResult] = useState<ProbeResult | null>(null);
  72. const scheme = Form.useWatch('scheme', form) ?? 'https';
  73. const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
  74. const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
  75. const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
  76. // Outbounds and balancers share one picker (like the panel-outbound selector);
  77. // when balancers exist they get a labeled group so it's clear the selection
  78. // routes through a balancer. Empty falls back to the placeholder ("Direct
  79. // connection") rather than a synthetic option, so it can't read as a second
  80. // "direct" next to a real freedom outbound.
  81. const outboundOptions = useMemo<
  82. ({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
  83. >(() => {
  84. const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
  85. if (!outboundGroups?.balancers.length) return outOpts;
  86. return [
  87. { label: t('pages.xray.Outbounds'), options: outOpts },
  88. { label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
  89. ];
  90. }, [outboundGroups, t]);
  91. useEffect(() => {
  92. if (!open) return;
  93. const base = defaultValues();
  94. const next: NodeFormValues = mode === 'edit' && node
  95. ? {
  96. ...base,
  97. ...(node as unknown as Partial<NodeFormValues>),
  98. id: node.id,
  99. scheme: (node.scheme as 'http' | 'https') || base.scheme,
  100. inboundSyncMode: (node.inboundSyncMode as 'all' | 'selected') || base.inboundSyncMode,
  101. inboundTags: node.inboundTags ?? [],
  102. }
  103. : base;
  104. if (next.scheme === 'http') next.tlsVerifyMode = 'skip';
  105. form.resetFields();
  106. form.setFieldsValue(next);
  107. setInboundOptions((next.inboundTags || []).map((tag) => ({ tag })));
  108. setTestResult(null);
  109. }, [open, mode, node, form]);
  110. const title = useMemo(
  111. () => (mode === 'edit' ? t('pages.nodes.editNode') : t('pages.nodes.addNode')),
  112. [mode, t],
  113. );
  114. function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
  115. return {
  116. id: values.id || 0,
  117. name: values.name.trim(),
  118. remark: values.remark?.trim() || '',
  119. scheme: values.scheme,
  120. address: values.address.trim(),
  121. port: values.port,
  122. basePath: values.basePath.trim() || '/',
  123. apiToken: values.apiToken.trim(),
  124. enable: values.enable,
  125. allowPrivateAddress: values.allowPrivateAddress,
  126. tlsVerifyMode: values.tlsVerifyMode,
  127. pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
  128. inboundSyncMode: values.inboundSyncMode,
  129. inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
  130. outboundTag: values.outboundTag || '',
  131. };
  132. }
  133. async function onTest() {
  134. try {
  135. await form.validateFields(['address', 'port']);
  136. } catch {
  137. return;
  138. }
  139. setTesting(true);
  140. setTestResult(null);
  141. try {
  142. const payload = buildPayload(form.getFieldsValue(true));
  143. const msg = await testConnection(payload);
  144. if (msg?.success && msg.obj) {
  145. setTestResult(msg.obj);
  146. } else {
  147. setTestResult({ status: 'offline', error: msg?.msg || 'unknown error' });
  148. }
  149. } finally {
  150. setTesting(false);
  151. }
  152. }
  153. async function onFetchPin() {
  154. try {
  155. await form.validateFields(['address', 'port']);
  156. } catch {
  157. return;
  158. }
  159. setFetchingPin(true);
  160. try {
  161. const payload = buildPayload(form.getFieldsValue(true));
  162. const msg = await fetchFingerprint(payload);
  163. if (msg?.success && msg.obj) {
  164. form.setFieldValue('pinnedCertSha256', msg.obj);
  165. messageApi.success(t('pages.nodes.pinFetched'));
  166. } else {
  167. messageApi.error(msg?.msg || t('pages.nodes.pinFetchFailed'));
  168. }
  169. } finally {
  170. setFetchingPin(false);
  171. }
  172. }
  173. async function onFetchInbounds() {
  174. try {
  175. await form.validateFields(['name', 'address', 'port', 'apiToken']);
  176. } catch {
  177. return;
  178. }
  179. setFetchingInbounds(true);
  180. try {
  181. const msg = await fetchInbounds(buildPayload(form.getFieldsValue(true)));
  182. if (msg?.success && Array.isArray(msg.obj)) {
  183. setInboundOptions(msg.obj);
  184. messageApi.success(t('pages.nodes.inboundsLoaded', { count: msg.obj.length }));
  185. } else {
  186. messageApi.error(msg?.msg || t('pages.nodes.inboundsLoadFailed'));
  187. }
  188. } finally {
  189. setFetchingInbounds(false);
  190. }
  191. }
  192. async function onFinish(values: NodeFormValues) {
  193. const result = NodeFormSchema.safeParse(values);
  194. if (!result.success) {
  195. messageApi.error(t(result.error.issues[0]?.message ?? 'pages.nodes.toasts.fillRequired'));
  196. return;
  197. }
  198. setSubmitting(true);
  199. try {
  200. const payload = buildPayload(result.data);
  201. const test = await testConnection(payload);
  202. const probe = test?.success ? test.obj : null;
  203. if (!probe || probe.status !== 'online') {
  204. setTestResult(probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') });
  205. return;
  206. }
  207. setTestResult(probe);
  208. const msg = await save(payload);
  209. if (msg?.success) {
  210. onOpenChange(false);
  211. }
  212. } finally {
  213. setSubmitting(false);
  214. }
  215. }
  216. function close() {
  217. if (!submitting) onOpenChange(false);
  218. }
  219. return (
  220. <>
  221. {messageContextHolder}
  222. <Modal
  223. open={open}
  224. title={title}
  225. confirmLoading={submitting}
  226. okText={t('save')}
  227. cancelText={t('cancel')}
  228. mask={{ closable: false }}
  229. width="640px"
  230. onOk={() => form.submit()}
  231. onCancel={close}
  232. >
  233. <Form
  234. form={form}
  235. layout="vertical"
  236. initialValues={defaultValues()}
  237. onFinish={onFinish}
  238. >
  239. <Row gutter={16}>
  240. <Col xs={24} md={12}>
  241. <Form.Item
  242. label={t('pages.nodes.name')}
  243. name="name"
  244. rules={[antdRule(NodeFormSchema.shape.name, t)]}
  245. >
  246. <Input placeholder={t('pages.nodes.namePlaceholder')} />
  247. </Form.Item>
  248. </Col>
  249. <Col xs={24} md={12}>
  250. <Form.Item label={t('pages.nodes.remark')} name="remark">
  251. <Input />
  252. </Form.Item>
  253. </Col>
  254. </Row>
  255. <Row gutter={16}>
  256. <Col xs={24} md={6}>
  257. <Form.Item label={t('pages.nodes.scheme')} name="scheme">
  258. <Select
  259. options={[
  260. { value: 'https', label: 'https' },
  261. { value: 'http', label: 'http' },
  262. ]}
  263. onChange={(value) => {
  264. if (value === 'http') form.setFieldValue('tlsVerifyMode', 'skip');
  265. }}
  266. />
  267. </Form.Item>
  268. </Col>
  269. <Col xs={24} md={12}>
  270. <Form.Item
  271. label={t('pages.nodes.address')}
  272. name="address"
  273. rules={[antdRule(NodeFormSchema.shape.address, t)]}
  274. >
  275. <Input placeholder={t('pages.nodes.addressPlaceholder')} />
  276. </Form.Item>
  277. </Col>
  278. <Col xs={24} md={6}>
  279. <Form.Item
  280. label={t('pages.nodes.port')}
  281. name="port"
  282. rules={[antdRule(NodeFormSchema.shape.port, t)]}
  283. >
  284. <InputNumber min={1} max={65535} style={{ width: '100%' }} />
  285. </Form.Item>
  286. </Col>
  287. </Row>
  288. <Row gutter={16}>
  289. <Col xs={24} md={12}>
  290. <Form.Item label={t('pages.nodes.basePath')} name="basePath">
  291. <Input placeholder="/" />
  292. </Form.Item>
  293. </Col>
  294. <Col xs={24} md={12}>
  295. <Form.Item
  296. label={t('pages.nodes.enable')}
  297. name="enable"
  298. valuePropName="checked"
  299. >
  300. <Switch />
  301. </Form.Item>
  302. </Col>
  303. </Row>
  304. <Form.Item
  305. label={t('pages.nodes.allowPrivateAddress')}
  306. name="allowPrivateAddress"
  307. valuePropName="checked"
  308. tooltip={t('pages.nodes.allowPrivateAddressHint')}
  309. >
  310. <Switch />
  311. </Form.Item>
  312. <Form.Item
  313. label={t('pages.nodes.tlsVerifyMode')}
  314. name="tlsVerifyMode"
  315. tooltip={t('pages.nodes.tlsVerifyModeHint')}
  316. >
  317. <Select
  318. disabled={scheme === 'http'}
  319. options={[
  320. { value: 'verify', label: t('pages.nodes.tlsVerify') },
  321. { value: 'pin', label: t('pages.nodes.tlsPin') },
  322. { value: 'skip', label: t('pages.nodes.tlsSkip') },
  323. { value: 'mtls', label: t('pages.nodes.tlsMtls') },
  324. ]}
  325. />
  326. </Form.Item>
  327. {tlsVerifyMode === 'skip' && (
  328. <Alert
  329. type="warning"
  330. showIcon
  331. style={{ marginBottom: 16 }}
  332. title={t('pages.nodes.tlsSkipWarning')}
  333. />
  334. )}
  335. {tlsVerifyMode === 'mtls' && (
  336. <Alert
  337. type="info"
  338. showIcon
  339. style={{ marginBottom: 16 }}
  340. title={t('pages.nodes.mtlsFormHint')}
  341. />
  342. )}
  343. {tlsVerifyMode === 'pin' && (
  344. <Form.Item
  345. label={t('pages.nodes.pinnedCert')}
  346. name="pinnedCertSha256"
  347. tooltip={t('pages.nodes.pinnedCertHint')}
  348. >
  349. <Input.Search
  350. placeholder={t('pages.nodes.pinnedCertPlaceholder')}
  351. enterButton={t('pages.nodes.fetchPin')}
  352. loading={fetchingPin}
  353. onSearch={onFetchPin}
  354. />
  355. </Form.Item>
  356. )}
  357. <Form.Item
  358. label={t('pages.nodes.apiToken')}
  359. name="apiToken"
  360. rules={[antdRule(NodeFormSchema.shape.apiToken, t)]}
  361. tooltip={t('pages.nodes.apiTokenHint')}
  362. >
  363. <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
  364. </Form.Item>
  365. <Form.Item
  366. label={t('pages.nodes.outboundTag')}
  367. name="outboundTag"
  368. tooltip={t('pages.nodes.outboundTagHint')}
  369. getValueProps={(v) => ({ value: (v as string) || undefined })}
  370. >
  371. <Select
  372. allowClear
  373. showSearch
  374. placeholder={t('pages.nodes.outboundTagPlaceholder')}
  375. options={outboundOptions}
  376. />
  377. </Form.Item>
  378. <Form.Item
  379. label={t('pages.nodes.inboundSyncMode')}
  380. name="inboundSyncMode"
  381. tooltip={t('pages.nodes.inboundSyncModeHint')}
  382. >
  383. <Select
  384. options={[
  385. { value: 'all', label: t('pages.nodes.allInbounds') },
  386. { value: 'selected', label: t('pages.nodes.selectedInbounds') },
  387. ]}
  388. />
  389. </Form.Item>
  390. {inboundSyncMode === 'selected' && (
  391. <Form.Item
  392. label={t('pages.nodes.inboundTags')}
  393. name="inboundTags"
  394. tooltip={t('pages.nodes.inboundTagsHint')}
  395. >
  396. <Select
  397. mode="multiple"
  398. allowClear
  399. loading={fetchingInbounds}
  400. placeholder={t('pages.nodes.inboundTagsPlaceholder')}
  401. popupRender={(menu) => (
  402. <>
  403. <Button type="text" block loading={fetchingInbounds} onClick={onFetchInbounds}>
  404. {t('pages.nodes.loadInbounds')}
  405. </Button>
  406. {menu}
  407. </>
  408. )}
  409. options={inboundOptions.map((inbound) => ({
  410. value: inbound.tag,
  411. label: `${inbound.remark || inbound.tag}${inbound.protocol ? ` (${inbound.protocol}:${inbound.port || 0})` : ''}`,
  412. }))}
  413. />
  414. </Form.Item>
  415. )}
  416. <div className="test-row">
  417. <Button type="default" loading={testing} onClick={onTest}>
  418. {t('pages.nodes.testConnection')}
  419. </Button>
  420. {testResult && (
  421. <div className="test-result">
  422. {testResult.status === 'online' ? (
  423. <Alert
  424. type="success"
  425. showIcon
  426. title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
  427. description={testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined}
  428. />
  429. ) : (
  430. <Alert
  431. type="error"
  432. showIcon
  433. title={t('pages.nodes.connectionFailed')}
  434. description={testResult.error}
  435. />
  436. )}
  437. </div>
  438. )}
  439. </div>
  440. </Form>
  441. </Modal>
  442. </>
  443. );
  444. }