import { useEffect, useMemo } 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 type { HostRecord } from '@/api/queries/useHostsQuery'; import type { HostFormValues } from '@/schemas/api/host'; import type { InboundOption } from '@/schemas/client'; import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { useNodesQuery } from '@/api/queries/useNodesQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from '@/pages/settings/catTabLabel'; import { HostFinalMaskForm, HostMuxForm, HostSockoptForm } from './json-forms'; // inboundId is optional in the form so a new host starts unselected (the Select // shows its placeholder instead of 0); the required rule enforces it on submit. type FormShape = Omit & { enable: boolean; inboundId?: number }; interface HostFormModalProps { open: boolean; mode: 'add' | 'edit'; host: HostRecord | null; inboundOptions: InboundOption[]; save: (payload: Partial) => 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 { inboundId: host?.inboundId, sortOrder: host?.sortOrder ?? 0, remark: host?.remark ?? '', serverDescription: host?.serverDescription ?? '', enable: host ? !host.isDisabled : true, isHidden: host?.isHidden ?? false, tags: host?.tags ?? [], address: host?.address ?? '', port: host?.port ?? 0, security: (host?.security as HostFormValues['security']) ?? 'same', sni: host?.sni ?? '', hostHeader: host?.hostHeader ?? '', path: host?.path ?? '', alpn: (host?.alpn as HostFormValues['alpn']) ?? [], fingerprint: host?.fingerprint as HostFormValues['fingerprint'], overrideSniFromAddress: host?.overrideSniFromAddress ?? false, keepSniBlank: host?.keepSniBlank ?? false, pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [], verifyPeerCertByName: host?.verifyPeerCertByName ?? false, 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 HostFormValues['excludeFromSubTypes']) ?? [], nodeGuids: host?.nodeGuids ?? [], mihomoIpVersion: host?.mihomoIpVersion as HostFormValues['mihomoIpVersion'], mihomoX25519: host?.mihomoX25519 ?? false, shuffleHost: host?.shuffleHost ?? false, }; } export default function HostFormModal({ open, mode, host, inboundOptions, save, onOpenChange }: HostFormModalProps) { const { t } = useTranslation(); const { isMobile } = useMediaQuery(); const [form] = Form.useForm(); // Drive conditional field visibility off the selected security, like the // legacy externalProxy form: same/none inherit fully and hide every TLS/cert // field; reality shows only the reality-relevant subset (its keys are // inherited from the inbound); tls shows the full TLS override set. const security = (Form.useWatch('security', form) ?? 'same') as string; const showTls = security === 'tls' || security === 'reality'; const showTlsExtras = security === 'tls'; useEffect(() => { if (open) form.setFieldsValue(defaultsFor(host)); }, [open, host, form]); 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 onOk = async () => { let values: FormShape; try { values = await form.validateFields(); } catch { return; } const { enable, ...rest } = values; const payload: Partial = { ...rest, isDisabled: !enable }; const res = await save(payload); if (res?.success) { message.success(t(mode === 'add' ? 'pages.hosts.toasts.add' : 'pages.hosts.toasts.update')); onOpenChange(false); } else if (res?.msg) { message.error(res.msg); } }; return ( onOpenChange(false)} okText={t('save')} cancelText={t('cancel')} destroyOnHidden width={isMobile ? '95vw' : 760} styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }} >
, t('pages.hosts.sections.basic'), isMobile), children: ( <> ), }, { key: 'security', forceRender: true, label: catTabLabel(, t('pages.hosts.sections.security'), isMobile), children: ( <> ({ value: v, label: v }))} /> ), }, ]} />
); }