| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383 |
- import { useEffect, useMemo, useState } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Form, Input, InputNumber, Modal, Select, Switch, Tabs, message } from 'antd';
- import {
- ProfileOutlined,
- SafetyCertificateOutlined,
- ControlOutlined,
- NodeIndexOutlined,
- SettingOutlined,
- PartitionOutlined,
- DeploymentUnitOutlined,
- RocketOutlined,
- } from '@ant-design/icons';
- import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
- import type { HostRecord } from '@/api/queries/useHostsQuery';
- import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
- import type { InboundOption } from '@/schemas/client';
- import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
- import { FormField, rhfZodValidate } from '@/components/form/rhf';
- import { useNodesQuery } from '@/api/queries/useNodesQuery';
- import { useMediaQuery } from '@/hooks/useMediaQuery';
- import { catTabLabel } from '@/pages/settings/catTabLabel';
- import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms';
- type FormShape = Omit<BulkAddHostValues, 'isDisabled'> & {
- enable: boolean;
- };
- interface HostFormModalProps {
- open: boolean;
- mode: 'add' | 'edit';
- host: HostRecord | null;
- inboundOptions: InboundOption[];
- existingHosts: HostRecord[];
- save: (payload: BulkAddHostValues) => Promise<{ success?: boolean; msg?: string } | undefined>;
- onOpenChange: (open: boolean) => void;
- }
- const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
- function defaultsFor(host: HostRecord | null): FormShape {
- return {
- inboundIds: host?.inboundIds ?? [],
- hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
- sortOrder: host?.sortOrder ?? 0,
- remark: host?.remark ?? '',
- serverDescription: host?.serverDescription ?? '',
- enable: host ? !host.isDisabled : true,
- isHidden: host?.isHidden ?? false,
- tags: host?.tags ?? [],
- port: host?.port ?? 0,
- security: (host?.security as BulkAddHostValues['security']) ?? 'same',
- sni: host?.sni ?? '',
- hostHeader: host?.hostHeader ?? '',
- path: host?.path ?? '',
- alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
- fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
- overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
- keepSniBlank: host?.keepSniBlank ?? false,
- pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
- verifyPeerCertByName: (host?.verifyPeerCertByName as string | undefined) ?? '',
- allowInsecure: host?.allowInsecure ?? false,
- echConfigList: host?.echConfigList ?? '',
- muxParams: asString(host?.muxParams),
- sockoptParams: asString(host?.sockoptParams),
- finalMask: host?.finalMask ?? '',
- vlessRoute: host?.vlessRoute ?? '',
- excludeFromSubTypes: (host?.excludeFromSubTypes as BulkAddHostValues['excludeFromSubTypes']) ?? [],
- nodeGuids: host?.nodeGuids ?? [],
- mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
- mihomoX25519: host?.mihomoX25519 ?? false,
- shuffleHost: host?.shuffleHost ?? false,
- };
- }
- export default function HostFormModal({ open, mode, host, inboundOptions, existingHosts, save, onOpenChange }: HostFormModalProps) {
- const { t } = useTranslation();
- const { isMobile } = useMediaQuery();
- const methods = useForm<FormShape>({ defaultValues: defaultsFor(host) });
- const [messageApi, messageContextHolder] = message.useMessage();
- const [loading, setLoading] = useState(false);
- const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
- const showTls = security === 'tls' || security === 'reality';
- const showTlsExtras = security === 'tls';
- useEffect(() => {
- if (open) {
- methods.reset(defaultsFor(host));
- setLoading(false);
- }
- }, [open, host, methods]);
- const { nodes } = useNodesQuery();
- const inboundSelectOptions = useMemo(
- () => inboundOptions.map((ib) => ({
- value: ib.id,
- label: ib.remark || ib.tag || `#${ib.id}`,
- })),
- [inboundOptions],
- );
- const nodeSelectOptions = useMemo(
- () => nodes
- .filter((n) => n.guid)
- .map((n) => ({ value: n.guid as string, label: n.name || n.remark || (n.guid as string) })),
- [nodes],
- );
- const alpnOptions = useMemo(() => Object.values(ALPN_OPTION).map((v) => ({ value: v, label: v })), []);
- const fpOptions = useMemo(() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })), []);
- const hostOptions = useMemo(() => {
- const addresses = new Set<string>();
- for (const h of existingHosts || []) {
- if (h.hosts) {
- for (const addr of h.hosts) {
- if (addr && addr.trim() !== '') {
- addresses.add(addr);
- }
- }
- }
- }
- return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
- }, [existingHosts]);
- const onFinish = async (values: FormShape) => {
- if (loading) return;
- const { enable, ...rest } = values;
- const isDisabled = !enable;
- const payload: BulkAddHostValues = {
- ...rest,
- hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
- isDisabled,
- };
- setLoading(true);
- try {
- const res = await save(payload);
- if (res?.success) {
- messageApi.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update'));
- onOpenChange(false);
- } else if (res?.msg) {
- messageApi.error(res.msg);
- }
- } catch (err) {
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
- return (
- <Modal
- open={open}
- title={t(mode === 'add' ? 'pages.hosts.addHost' : 'pages.hosts.editHost')}
- onOk={methods.handleSubmit(onFinish)}
- onCancel={() => onOpenChange(false)}
- confirmLoading={loading}
- okText={t('save')}
- cancelText={t('cancel')}
- destroyOnHidden
- width={isMobile ? '95vw' : 760}
- styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }}
- >
- {messageContextHolder}
- <FormProvider {...methods}>
- <Form
- colon={false}
- labelCol={{ sm: { span: 8 } }}
- wrapperCol={{ sm: { span: 14 } }}
- labelWrap
- >
- <Tabs
- defaultActiveKey="basic"
- items={[
- {
- key: 'basic',
- forceRender: true,
- label: catTabLabel(<ProfileOutlined />, t('pages.hosts.sections.basic'), isMobile),
- children: (
- <>
- <FormField name="remark" label={t('pages.hosts.fields.remark')} tooltip={t('pages.hosts.hints.remark')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.remark) }}>
- <Input maxLength={256} />
- </FormField>
- <FormField name="serverDescription" label={t('pages.hosts.fields.serverDescription')} tooltip={t('pages.hosts.hints.serverDescription')}>
- <Input maxLength={64} />
- </FormField>
- <FormField name="inboundIds" label={t('pages.hosts.fields.inbound')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.inboundIds) }}>
- <Select
- mode="multiple"
- options={inboundSelectOptions}
- showSearch={{ optionFilterProp: 'label' }}
- placeholder={t('pages.hosts.selectInbound')}
- />
- </FormField>
- <FormField name="hosts" label={t('pages.hosts.fields.address')} tooltip={t('pages.hosts.hints.address')} rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.hosts) }}>
- <Select
- mode="tags"
- options={hostOptions}
- tokenSeparators={[',', ';', ' ']}
- placeholder="cdn.example.com, cdn2.example.com:443"
- />
- </FormField>
- <FormField name="port" label={t('pages.hosts.fields.port')} tooltip={t('pages.hosts.hints.port')}>
- <InputNumber min={0} max={65535} />
- </FormField>
- <FormField name="tags" label={t('pages.hosts.fields.tags')} tooltip={t('pages.hosts.hints.tags')}>
- <Select mode="tags" allowClear tokenSeparators={[',']} />
- </FormField>
- <FormField name="nodeGuids" label={t('pages.hosts.fields.nodeGuids')} tooltip={t('pages.hosts.hints.nodeGuids')}>
- <Select mode="multiple" allowClear options={nodeSelectOptions} showSearch={{ optionFilterProp: 'label' }} />
- </FormField>
- <FormField name="enable" label={t('pages.hosts.fields.enable')} valueProp="checked">
- <Switch />
- </FormField>
- </>
- ),
- },
- {
- key: 'security',
- forceRender: true,
- label: catTabLabel(<SafetyCertificateOutlined />, t('pages.hosts.sections.security'), isMobile),
- children: (
- <>
- <FormField name="security" label={t('pages.hosts.fields.security')}>
- <Select
- options={['same', 'tls', 'none', 'reality'].map((v) => ({ value: v, label: v }))}
- />
- </FormField>
- {showTls && (
- <>
- <FormField name="sni" label={t('pages.hosts.fields.sni')}>
- <Input />
- </FormField>
- <FormField name="overrideSniFromAddress" label={t('pages.hosts.fields.overrideSniFromAddress')} valueProp="checked">
- <Switch />
- </FormField>
- <FormField name="keepSniBlank" label={t('pages.hosts.fields.keepSniBlank')} valueProp="checked">
- <Switch />
- </FormField>
- <FormField name="fingerprint" label={t('pages.hosts.fields.fingerprint')}>
- <Select allowClear options={fpOptions} />
- </FormField>
- </>
- )}
- {showTlsExtras && (
- <>
- <FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
- <Select mode="multiple" allowClear options={alpnOptions} />
- </FormField>
- <FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
- <Select mode="tags" allowClear tokenSeparators={[',']} />
- </FormField>
- <FormField name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
- <Input placeholder="example.com" />
- </FormField>
- <FormField name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valueProp="checked">
- <Switch />
- </FormField>
- <FormField name="echConfigList" label={t('pages.hosts.fields.echConfigList')}>
- <Input.TextArea rows={2} />
- </FormField>
- </>
- )}
- </>
- ),
- },
- {
- key: 'advanced',
- forceRender: true,
- label: catTabLabel(<ControlOutlined />, t('pages.hosts.sections.advanced'), isMobile),
- children: (
- <Tabs
- size="small"
- defaultActiveKey="adv-general"
- items={[
- {
- key: 'adv-general',
- forceRender: true,
- label: catTabLabel(<SettingOutlined />, t('pages.hosts.sections.general'), isMobile),
- children: (
- <>
- <FormField name="hostHeader" label={t('pages.hosts.fields.hostHeader')}>
- <Input />
- </FormField>
- <FormField name="path" label={t('pages.hosts.fields.path')}>
- <Input />
- </FormField>
- <FormField name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
- <Input placeholder="443" />
- </FormField>
- <FormField name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
- <Select
- mode="multiple"
- allowClear
- options={['raw', 'json', 'clash'].map((v) => ({ value: v, label: v }))}
- />
- </FormField>
- </>
- ),
- },
- {
- key: 'adv-mux',
- forceRender: true,
- label: catTabLabel(<PartitionOutlined />, t('pages.hosts.fields.muxParams'), isMobile),
- children: (
- <Form.Item noStyle>
- <Controller
- control={methods.control}
- name="muxParams"
- render={({ field }) => (
- <HostMuxForm value={field.value} onChange={field.onChange} />
- )}
- />
- </Form.Item>
- ),
- },
- {
- key: 'adv-sockopt',
- forceRender: true,
- label: catTabLabel(<DeploymentUnitOutlined />, t('pages.hosts.fields.sockoptParams'), isMobile),
- children: (
- <Form.Item noStyle>
- <Controller
- control={methods.control}
- name="sockoptParams"
- render={({ field }) => (
- <HostSockoptForm value={field.value} onChange={field.onChange} />
- )}
- />
- </Form.Item>
- ),
- },
- {
- key: 'adv-finalmask',
- forceRender: true,
- label: catTabLabel(<RocketOutlined />, t('pages.hosts.fields.finalMask'), isMobile),
- children: (
- <Form.Item noStyle>
- <Controller
- control={methods.control}
- name="finalMask"
- render={({ field }) => (
- <HostFinalMaskForm value={field.value} onChange={field.onChange} />
- )}
- />
- </Form.Item>
- ),
- },
- ]}
- />
- ),
- },
- {
- key: 'clash',
- forceRender: true,
- label: catTabLabel(<NodeIndexOutlined />, t('pages.hosts.sections.clash'), isMobile),
- children: (
- <>
- <FormField name="mihomoIpVersion" label={t('pages.hosts.fields.mihomoIpVersion')}>
- <Select
- allowClear
- options={['dual', 'ipv4', 'ipv6', 'ipv4-prefer', 'ipv6-prefer'].map((v) => ({ value: v, label: v }))}
- />
- </FormField>
- <FormField name="mihomoX25519" label={t('pages.hosts.fields.mihomoX25519')} valueProp="checked">
- <Switch />
- </FormField>
- <FormField name="shuffleHost" label={t('pages.hosts.fields.shuffleHost')} valueProp="checked">
- <Switch />
- </FormField>
- </>
- ),
- },
- ]}
- />
- </Form>
- </FormProvider>
- </Modal>
- );
- }
|