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 & { 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({ 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(); 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 ( onOpenChange(false)} confirmLoading={loading} okText={t('save')} cancelText={t('cancel')} destroyOnHidden width={isMobile ? '95vw' : 760} styles={{ body: { maxHeight: '70vh', overflowY: 'auto', overflowX: 'hidden' } }} > {messageContextHolder}
, t('pages.hosts.sections.basic'), isMobile), children: ( <> ), }, { key: 'security', forceRender: true, label: catTabLabel(, t('pages.hosts.sections.security'), isMobile), children: ( <> )} ), }, { key: 'advanced', forceRender: true, label: catTabLabel(, t('pages.hosts.sections.advanced'), isMobile), children: ( , t('pages.hosts.sections.general'), isMobile), children: ( <> ({ value: v, label: v }))} /> ), }, ]} />
); }