| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- import { useEffect, useMemo } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd';
- import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
- import { FormField, rhfZodValidate } from '@/components/form/rhf';
- import SelectAllClearButtons from '@/components/form/SelectAllClearButtons';
- import { useInboundOptions } from '@/api/queries/useInboundOptions';
- import { formatInboundLabel } from '@/lib/inbounds/label';
- import {
- SubBalancerFormSchema,
- SubBalancerStrategySchema,
- type SubBalancer,
- type SubBalancerFormValues,
- type SubBalancerStrategy,
- } from '@/schemas/subBalancer';
- // The JSON subscription only builds proxy outbounds for these protocols;
- // mtproto has no proxy-outbound case, so it is excluded from balancer members.
- const MULTI_CLIENT_PROTOCOLS = new Set([
- 'shadowsocks',
- 'vless',
- 'vmess',
- 'trojan',
- 'hysteria',
- 'wireguard',
- 'masque',
- ]);
- const STRATEGY_LABEL_KEYS: Record<SubBalancerStrategy, string> = {
- leastLoad: 'pages.settings.subBalancers.strategyLeastLoad',
- leastPing: 'pages.settings.subBalancers.strategyLeastPing',
- random: 'pages.settings.subBalancers.strategyRandom',
- roundRobin: 'pages.settings.subBalancers.strategyRoundRobin',
- };
- function initialState(balancer: SubBalancer | null): SubBalancerFormValues {
- return {
- remark: balancer?.remark ?? '',
- strategy: balancer?.strategy ?? 'random',
- inboundIds: [...(balancer?.inboundIds ?? [])],
- memberWeights: balancer?.memberWeights ? { ...balancer.memberWeights } : undefined,
- sortOrder: balancer?.sortOrder ?? 1,
- enabled: balancer?.enabled ?? true,
- };
- }
- interface SubBalancerFormModalProps {
- open: boolean;
- balancer: SubBalancer | null;
- onClose: () => void;
- onConfirm: (values: SubBalancerFormValues) => void;
- }
- export default function SubBalancerFormModal({
- open,
- balancer,
- onClose,
- onConfirm,
- }: SubBalancerFormModalProps) {
- const { t } = useTranslation();
- const [messageApi, messageContextHolder] = message.useMessage();
- const methods = useForm<SubBalancerFormValues>({ defaultValues: initialState(balancer) });
- const isEdit = balancer != null;
- useEffect(() => {
- if (open) methods.reset(initialState(balancer));
- }, [open, balancer, methods]);
- const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
- const strategy = useWatch({ control: methods.control, name: 'strategy' });
- // Weights only make sense for leastLoad; the fields hide but keep their
- // values so an accidental toggle away and back loses nothing until submit.
- const showWeights = strategy === 'leastLoad';
- const { data: inboundOptionsRaw } = useInboundOptions();
- const inboundOptions = useMemo(
- () =>
- (inboundOptionsRaw ?? [])
- .filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
- .filter((ib) => ib.enable || (inboundIds || []).includes(ib.id))
- .map((ib) => ({
- label: formatInboundLabel(ib.tag, ib.remark),
- value: ib.id,
- title: formatInboundLabel(ib.tag, ib.remark),
- })),
- [inboundOptionsRaw, inboundIds],
- );
- function onFinish(values: SubBalancerFormValues) {
- const candidate: SubBalancerFormValues = { ...values };
- if (candidate.memberWeights) {
- const cleaned = Object.fromEntries(
- Object.entries(candidate.memberWeights).filter(
- ([, v]) => typeof v === 'number' && Number.isFinite(v) && v > 0,
- ),
- );
- candidate.memberWeights = Object.keys(cleaned).length > 0 ? cleaned : undefined;
- }
- // xray ignores costs on every strategy but leastLoad — never send them.
- if (candidate.strategy !== 'leastLoad') {
- delete candidate.memberWeights;
- }
- const parsed = SubBalancerFormSchema.safeParse(candidate);
- if (!parsed.success) {
- messageApi.error(
- t(parsed.error.issues[0]?.message ?? 'pages.settings.subBalancers.errRemarkRequired'),
- );
- return;
- }
- onConfirm(parsed.data);
- }
- const strategies = SubBalancerStrategySchema.options.map((value) => ({
- value,
- label: t(STRATEGY_LABEL_KEYS[value]),
- }));
- return (
- <Modal
- open={open}
- title={
- isEdit
- ? `${t('edit')} ${t('pages.settings.subBalancers.title')}`
- : `+ ${t('pages.settings.subBalancers.add')}`
- }
- okText={isEdit ? t('pages.clients.submitEdit') : t('create')}
- cancelText={t('close')}
- mask={{ closable: false }}
- width="640px"
- onOk={methods.handleSubmit(onFinish)}
- onCancel={onClose}
- >
- {messageContextHolder}
- <FormProvider {...methods}>
- <Form layout="vertical">
- <FormField
- label={t('pages.settings.subBalancers.remark')}
- name="remark"
- required
- rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.remark) }}
- >
- <Input placeholder={t('pages.settings.subBalancers.remarkPlaceholder')} />
- </FormField>
- <FormField label={t('pages.settings.subBalancers.strategy')} name="strategy" required>
- <Select options={strategies} />
- </FormField>
- <FormField
- label={t('pages.settings.subBalancers.sortOrder')}
- name="sortOrder"
- required
- tooltip={t('pages.settings.subBalancers.sortOrderHelp')}
- rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.sortOrder) }}
- >
- <InputNumber min={1} precision={0} style={{ width: '100%' }} />
- </FormField>
- <FormField
- label={t('pages.settings.subBalancers.inbounds')}
- name="inboundIds"
- required
- rules={{ validate: rhfZodValidate(SubBalancerFormSchema.shape.inboundIds) }}
- >
- <Select
- mode="multiple"
- options={inboundOptions}
- maxTagCount="responsive"
- listHeight={220}
- showSearch={{ optionFilterProp: 'label' }}
- />
- </FormField>
- <SelectAllClearButtons
- options={inboundOptions}
- value={inboundIds || []}
- onChange={(v) => methods.setValue('inboundIds', v, { shouldDirty: true })}
- />
- {showWeights && (inboundIds ?? []).length > 0 && (
- <Form.Item
- className="sub-balancer-weights"
- label={t('pages.settings.subBalancers.weights')}
- tooltip={t('pages.settings.subBalancers.weightsHelp')}
- style={{ marginBottom: 16 }}
- >
- <div
- style={{
- display: 'flex',
- flexDirection: 'column',
- gap: 8,
- maxHeight: 220,
- overflowY: 'auto',
- paddingRight: 4,
- }}
- >
- {(inboundIds ?? []).map((id) => {
- const option = inboundOptions.find((o) => o.value === id);
- return (
- <div key={id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
- <span
- title={option?.title}
- style={{
- minWidth: 0,
- flex: 1,
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- }}
- >
- {option?.label ?? `#${id}`}
- </span>
- <Controller
- control={methods.control}
- name={`memberWeights.${id}`}
- render={({ field }) => (
- <InputNumber
- min={0.1}
- step={0.1}
- precision={1}
- style={{ width: 120 }}
- value={(field.value as number | undefined) ?? 1}
- onChange={(v) => field.onChange(typeof v === 'number' ? v : undefined)}
- />
- )}
- />
- </div>
- );
- })}
- </div>
- </Form.Item>
- )}
- <FormField
- label={t('pages.settings.subBalancers.enabled')}
- name="enabled"
- valueProp="checked"
- >
- <Switch />
- </FormField>
- </Form>
- </FormProvider>
- </Modal>
- );
- }
|