RuleFormModal.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import { useEffect, useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd';
  4. import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
  5. import { FormProvider, useForm, useWatch } from 'react-hook-form';
  6. import { InputAddon } from '@/components/ui';
  7. import { FormField } from '@/components/form/rhf';
  8. import { useInboundOptions } from '@/api/queries/useInboundOptions';
  9. import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
  10. import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
  11. export interface RoutingRule {
  12. enabled?: boolean;
  13. type?: string;
  14. domain?: string | string[];
  15. ip?: string | string[];
  16. port?: string;
  17. sourcePort?: string;
  18. vlessRoute?: string;
  19. network?: string;
  20. sourceIP?: string | string[];
  21. user?: string | string[];
  22. inboundTag?: string[];
  23. protocol?: string[];
  24. attrs?: Record<string, string>;
  25. outboundTag?: string;
  26. balancerTag?: string;
  27. [key: string]: unknown;
  28. }
  29. interface RuleFormModalProps {
  30. open: boolean;
  31. rule: RoutingRule | null;
  32. inboundTags: string[];
  33. outboundTags: string[];
  34. balancerTags: string[];
  35. onClose: () => void;
  36. onConfirm: (rule: Record<string, unknown>) => void;
  37. }
  38. const initialForm = (): RuleFormValues => ({
  39. enabled: true,
  40. domain: '',
  41. ip: '',
  42. port: '',
  43. sourcePort: '',
  44. vlessRoute: '',
  45. network: '',
  46. sourceIP: '',
  47. user: '',
  48. inboundTag: [],
  49. protocol: [],
  50. attrs: [],
  51. outboundTag: '',
  52. balancerTag: '',
  53. });
  54. const NETWORKS = ['', 'tcp', 'udp', 'tcp,udp'];
  55. const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
  56. function csv(value: string): string[] {
  57. if (!value) return [];
  58. return value.split(',').map((s) => s.trim()).filter(Boolean);
  59. }
  60. export default function RuleFormModal({
  61. open,
  62. rule,
  63. inboundTags,
  64. outboundTags,
  65. balancerTags,
  66. onClose,
  67. onConfirm,
  68. }: RuleFormModalProps) {
  69. const { t } = useTranslation();
  70. const methods = useForm<RuleFormValues>({ defaultValues: initialForm() });
  71. const isEdit = rule != null;
  72. const { data: inboundOptions } = useInboundOptions();
  73. const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
  74. useEffect(() => {
  75. if (!open) return;
  76. if (rule) {
  77. methods.reset({
  78. enabled: rule.enabled !== false,
  79. domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
  80. ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
  81. port: rule.port || '',
  82. sourcePort: rule.sourcePort || '',
  83. vlessRoute: rule.vlessRoute || '',
  84. network: rule.network || '',
  85. sourceIP: Array.isArray(rule.sourceIP) ? rule.sourceIP.join(',') : rule.sourceIP || '',
  86. user: Array.isArray(rule.user) ? rule.user.join(',') : rule.user || '',
  87. inboundTag: rule.inboundTag || [],
  88. protocol: rule.protocol || [],
  89. attrs: rule.attrs ? Object.entries(rule.attrs) : [],
  90. outboundTag: rule.outboundTag || '',
  91. balancerTag: rule.balancerTag || '',
  92. });
  93. } else {
  94. methods.reset(initialForm());
  95. }
  96. }, [open, rule, methods]);
  97. const attrs = useWatch({ control: methods.control, name: 'attrs' }) ?? [];
  98. function submit() {
  99. const validated = RuleFormSchema.safeParse(methods.getValues());
  100. if (!validated.success) return;
  101. const v = validated.data;
  102. const built: Record<string, unknown> = {
  103. type: 'field',
  104. enabled: v.enabled,
  105. domain: csv(v.domain),
  106. ip: csv(v.ip),
  107. port: v.port,
  108. sourcePort: v.sourcePort,
  109. vlessRoute: v.vlessRoute,
  110. network: v.network,
  111. sourceIP: csv(v.sourceIP),
  112. user: csv(v.user),
  113. inboundTag: v.inboundTag,
  114. protocol: v.protocol,
  115. attrs: Object.fromEntries(v.attrs.filter(([k]) => k)),
  116. outboundTag: v.outboundTag === '' ? undefined : v.outboundTag,
  117. balancerTag: v.balancerTag === '' ? undefined : v.balancerTag,
  118. };
  119. const managedKeys = new Set(Object.keys(built));
  120. const out: Record<string, unknown> = {};
  121. if (rule) {
  122. for (const [key, value] of Object.entries(rule)) {
  123. if (!managedKeys.has(key) && value !== undefined) out[key] = value;
  124. }
  125. }
  126. for (const [k, v] of Object.entries(built)) {
  127. if (v == null) continue;
  128. if (Array.isArray(v) && v.length === 0) continue;
  129. if (typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 0) continue;
  130. if (v === '') continue;
  131. out[k] = v;
  132. }
  133. onConfirm(out);
  134. }
  135. const title = isEdit
  136. ? `${t('edit')} ${t('pages.xray.Routings')}`
  137. : `+ ${t('pages.xray.Routings')}`;
  138. const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
  139. return (
  140. <Modal
  141. open={open}
  142. title={title}
  143. okText={okText}
  144. cancelText={t('close')}
  145. mask={{ closable: false }}
  146. width={640}
  147. onOk={submit}
  148. onCancel={onClose}
  149. >
  150. <FormProvider {...methods}>
  151. <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
  152. <FormField name="enabled" label={t('enable')} valueProp="checked">
  153. <Switch disabled={isApiRule(rule ?? {})} />
  154. </FormField>
  155. <FormField
  156. name="sourceIP"
  157. label={
  158. <Tooltip title={t('pages.xray.rules.useComma')}>
  159. {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
  160. </Tooltip>
  161. }
  162. >
  163. <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
  164. </FormField>
  165. <FormField
  166. name="sourcePort"
  167. label={
  168. <Tooltip title={t('pages.xray.rules.useComma')}>
  169. {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
  170. </Tooltip>
  171. }
  172. >
  173. <Input placeholder="53,443,1000-2000" />
  174. </FormField>
  175. <FormField
  176. name="vlessRoute"
  177. label={
  178. <Tooltip title={t('pages.xray.rules.useComma')}>
  179. {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
  180. </Tooltip>
  181. }
  182. >
  183. <Input placeholder="53,443,1000-2000" />
  184. </FormField>
  185. <FormField name="network" label={t('pages.inbounds.network')}>
  186. <Select options={NETWORKS.map((n) => ({ value: n, label: n || '(any)' }))} />
  187. </FormField>
  188. <FormField name="protocol" label={t('pages.inbounds.protocol')}>
  189. <Select mode="multiple" options={PROTOCOLS.map((p) => ({ value: p, label: p }))} />
  190. </FormField>
  191. <Form.Item label={t('pages.xray.ruleForm.attributes')}>
  192. <Button
  193. size="small"
  194. aria-label={t('add')}
  195. icon={<PlusOutlined />}
  196. onClick={() => methods.setValue('attrs', [...attrs, ['', ''] as [string, string]])}
  197. />
  198. </Form.Item>
  199. <Form.Item wrapperCol={{ span: 24 }}>
  200. {attrs.map((attr, idx) => (
  201. <Space.Compact key={idx} block className="mb-8">
  202. <InputAddon>{`${idx + 1}`}</InputAddon>
  203. <Input
  204. value={attr[0]}
  205. aria-label={t('pages.nodes.name')}
  206. placeholder={t('pages.nodes.name')}
  207. onChange={(e) => {
  208. const next = attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
  209. methods.setValue('attrs', next);
  210. }}
  211. />
  212. <Input
  213. value={attr[1]}
  214. aria-label={t('pages.xray.ruleForm.value')}
  215. placeholder={t('pages.xray.ruleForm.value')}
  216. onChange={(e) => {
  217. const next = attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
  218. methods.setValue('attrs', next);
  219. }}
  220. />
  221. <Button
  222. aria-label={t('remove')}
  223. icon={<MinusOutlined />}
  224. onClick={() => methods.setValue('attrs', attrs.filter((_, i) => i !== idx))}
  225. />
  226. </Space.Compact>
  227. ))}
  228. </Form.Item>
  229. <FormField
  230. name="ip"
  231. label={
  232. <Tooltip title={t('pages.xray.rules.useComma')}>
  233. IP <QuestionCircleOutlined aria-hidden="true" />
  234. </Tooltip>
  235. }
  236. >
  237. <Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
  238. </FormField>
  239. <FormField
  240. name="domain"
  241. label={
  242. <Tooltip title={t('pages.xray.rules.useComma')}>
  243. {t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
  244. </Tooltip>
  245. }
  246. >
  247. <Input placeholder="google.com, geosite:cn" />
  248. </FormField>
  249. <FormField
  250. name="user"
  251. label={
  252. <Tooltip title={t('pages.xray.rules.useComma')}>
  253. {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
  254. </Tooltip>
  255. }
  256. >
  257. <Input placeholder="email address" />
  258. </FormField>
  259. <FormField
  260. name="port"
  261. label={
  262. <Tooltip title={t('pages.xray.rules.useComma')}>
  263. {t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
  264. </Tooltip>
  265. }
  266. >
  267. <Input placeholder="53,443,1000-2000" />
  268. </FormField>
  269. <FormField name="inboundTag" label={t('pages.xray.ruleForm.inboundTags')}>
  270. <Select
  271. mode="multiple"
  272. options={inboundTags.map((tag) => ({ value: tag, label: formatInboundTag(tag, remarkByTag) }))}
  273. />
  274. </FormField>
  275. <FormField name="outboundTag" label={t('pages.xray.ruleForm.outboundTag')}>
  276. <Select options={outboundTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
  277. </FormField>
  278. <FormField
  279. name="balancerTag"
  280. label={
  281. <Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
  282. {t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
  283. </Tooltip>
  284. }
  285. >
  286. <Select options={balancerTags.map((tag) => ({ value: tag, label: tag || '(none)' }))} />
  287. </FormField>
  288. </Form>
  289. </FormProvider>
  290. </Modal>
  291. );
  292. }