SubBalancerFormModal.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import { useEffect, useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
  4. import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
  5. import { FormField, rhfZodValidate } from '@/components/form/rhf';
  6. import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
  7. import { useInboundOptions } from '@/api/queries/useInboundOptions';
  8. import { formatInboundLabel } from '@/lib/inbounds/label';
  9. import {
  10. SubBalancerFormSchema,
  11. SubBalancerStrategySchema,
  12. type SubBalancer,
  13. type SubBalancerFormValues,
  14. type SubBalancerStrategy,
  15. } from '@/schemas/subBalancer';
  16. // The JSON subscription only builds proxy outbounds for these protocols;
  17. // mtproto has no proxy-outbound case, so it is excluded from balancer members.
  18. const MULTI_CLIENT_PROTOCOLS = new Set([
  19. 'shadowsocks',
  20. 'vless',
  21. 'vmess',
  22. 'trojan',
  23. 'hysteria',
  24. 'wireguard',
  25. 'masque',
  26. ]);
  27. const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
  28. leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
  29. leastPing: 'pages.settings.subBalancers.strategyLeastPing',
  30. random: 'pages.settings.subBalancers.strategyRandom',
  31. roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
  32. };
  33. function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
  34. return {
  35. remark: balancer?.remark ?? '',
  36. strategy: balancer?.strategy ?? 'random',
  37. inboundIds: [...(balancer?.inboundIds ?? [])],
  38. memberWeights: balancer?.memberWeights ? { ...balancer.memberWeights } : undefined,
  39. sortOrder: balancer?.sortOrder ?? 1,
  40. enabled: balancer?.enabled ?? true,
  41. };
  42. }
  43. interface SubBalancerFormModalProps {
  44. open: boolean;
  45. balancer: SubBalancer | null;
  46. onClose: () => void;
  47. onConfirm: (values: SubBalancerFormValues) => void;
  48. }
  49. export default function SubBalancerFormModal({
  50. open,
  51. balancer,
  52. onClose,
  53. onConfirm,
  54. }: SubBalancerFormModalProps) {
  55. const { t } = useTranslation();
  56. const [messageApi, messageContextHolder] = message.useMessage();
  57. const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
  58. const isEdit = balancer != null;
  59. useEffect(() => {
  60. if (open) methods.reset(initialState(balancer));
  61. }, [open, balancer, methods]);
  62. const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
  63. const strategy = useWatch({ control: methods.control, name: 'strategy' });
  64. // Weights only make sense for leastLoad; the fields hide but keep their
  65. // values so an accidental toggle away and back loses nothing until submit.
  66. const showWeights = strategy === 'leastLoad';
  67. const { data: inboundOptionsRaw } = useInboundOptions();
  68. const inboundOptions = useMemo(
  69. () =>
  70. (inboundOptionsRaw ?? [])
  71. .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
  72. .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
  73. .map((ib) => ({
  74. label: formatInboundLabel(ib.tag, ib.remark),
  75. value: ib.id,
  76. title: formatInboundLabel(ib.tag, ib.remark),
  77. })),
  78. [inboundOptionsRaw, inboundIds],
  79. );
  80. function onFinish(values: SubBalancerFormValues) {
  81. const candidate: SubBalancerFormValues = { ...values };
  82. if (candidate.memberWeights) {
  83. const cleaned = Object.fromEntries(
  84. Object.entries(candidate.memberWeights).filter(
  85. ([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0,
  86. ),
  87. );
  88. candidate.memberWeights = Object.keys(cleaned).length > 0 ? cleaned : undefined;
  89. }
  90. // xray ignores costs on every strategy but leastLoad — never send them.
  91. if (candidate.strategy !== 'leastLoad') {
  92. delete candidate.memberWeights;
  93. }
  94. const parsed = SubBalancerFormSchema.safeParse(candidate);
  95. if (!parsed.success) {
  96. messageApi.error(
  97. t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
  98. );
  99. return;
  100. }
  101. onConfirm(parsed.data);
  102. }
  103. const strategies = SubBalancerStrategySchema.options.map((value) => ({
  104. value,
  105. label: t(STRATEGY_LABEL_KEYS[value]),
  106. }));
  107. return (
  108. <Modal
  109. open={open}
  110. title={
  111. isEdit
  112. ? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
  113. : `+ ${t('pages.settings.subBalancers.add')}`
  114. }
  115. okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
  116. cancelText={t('close')}
  117. mask={{ closable: false }}
  118. width="640px"
  119. onOk={methods.handleSubmit(onFinish)}
  120. onCancel={onClose}
  121. >
  122. {messageContextHolder}
  123. <FormProvider {...methods}>
  124. <Form layout="vertical">
  125. <FormField
  126. label={t('pages.settings.subBalancers.remark')}
  127. name="remark"
  128. required
  129. rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
  130. >
  131. <Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
  132. </FormField>
  133. <FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
  134. <Select options={strategies} />
  135. </FormField>
  136. <FormField
  137. label={t('pages.settings.subBalancers.sortOrder')}
  138. name="sortOrder"
  139. required
  140. tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
  141. rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
  142. >
  143. <InputNumber min={1} precision={0} style={{ width: '100%' }} />
  144. </FormField>
  145. <FormField
  146. label={t('pages.settings.subBalancers.inbounds')}
  147. name="inboundIds"
  148. required
  149. rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
  150. >
  151. <Select
  152. mode="multiple"
  153. options={inboundOptions}
  154. maxTagCount="responsive"
  155. listHeight={220}
  156. showSearch={{ optionFilterProp: 'label' }}
  157. />
  158. </FormField>
  159. <SelectAllClearButtons
  160. options={inboundOptions}
  161. value={inboundIds || []}
  162. onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
  163. />
  164. {showWeights && (inboundIds ?? []).length > 0 && (
  165. <Form.Item
  166. className="sub-balancer-weights"
  167. label={t('pages.settings.subBalancers.weights')}
  168. tooltip={t('pages.settings.subBalancers.weightsHelp')}
  169. style={{ marginBottom: 16 }}
  170. >
  171. <div
  172. style={{
  173. display: 'flex',
  174. flexDirection: 'column',
  175. gap: 8,
  176. maxHeight: 220,
  177. overflowY: 'auto',
  178. paddingRight: 4,
  179. }}
  180. >
  181. {(inboundIds ?? []).map((id) => {
  182. const option = inboundOptions.find((o) => o.value === id);
  183. return (
  184. <div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
  185. <span
  186. title={option?.title}
  187. style={{
  188. minWidth: 0,
  189. flex: 1,
  190. overflow: 'hidden',
  191. textOverflow: 'ellipsis',
  192. }}
  193. >
  194. {option?.label ?? `#${id}`}
  195. </span>
  196. <Controller
  197. control={methods.control}
  198. name={`memberWeights.${id}`}
  199. render={({ field }) => (
  200. <InputNumber
  201. min={0.1}
  202. step={0.1}
  203. precision={1}
  204. style={{ width: 120 }}
  205. value={(field.value as number | undefined) ?? 1}
  206. onChange={(v) => field.onChange(typeof v === 'number' ? v : undefined)}
  207. />
  208. )}
  209. />
  210. </div>
  211. );
  212. })}
  213. </div>
  214. </Form.Item>
  215. )}
  216. <FormField
  217. label={t('pages.settings.subBalancers.enabled')}
  218. name="enabled"
  219. valueProp="checked"
  220. >
  221. <Switch />
  222. </FormField>
  223. </Form>
  224. </FormProvider>
  225. </Modal>
  226. );
  227. }