import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { Checkbox, Form, Input, InputNumber, Modal, Select, Switch, Tabs, Tooltip, message, } from 'antd'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter } from '@/utils'; import { rawInboundToFormValues, formValuesToWirePayload, } from '@/lib/xray/inbound-form-adapter'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; import { InboundFormBaseSchema, InboundFormSchema, type InboundFormValues, } from '@/schemas/forms/inbound-form'; import { antdRule } from '@/utils/zodForm'; import { Protocols, SNIFFING_OPTION } from '@/schemas/primitives'; import DateTimePicker from '@/components/DateTimePicker'; import type { DBInbound } from '@/models/dbinbound'; import type { NodeRecord } from '@/api/queries/useNodesQuery'; // Pattern A rewrite of InboundFormModal. Built as a sibling file so the // build stays green while the rewrite progresses section by section. // InboundsPage continues to render the old InboundFormModal.tsx until the // atomic swap at the end (Core Decision 7). const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p })); const TRAFFIC_RESETS = ['never', 'hourly', 'daily', 'weekly', 'monthly'] as const; const NODE_ELIGIBLE_PROTOCOLS = new Set([ Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA, Protocols.WIREGUARD, ]); interface InboundFormModalProps { open: boolean; onClose: () => void; onSaved: () => void; mode: 'add' | 'edit'; dbInbound: DBInbound | null; dbInbounds: DBInbound[]; availableNodes?: NodeRecord[]; } function buildAddModeValues(): InboundFormValues { const settings = createDefaultInboundSettings('vless') ?? undefined; return rawInboundToFormValues({ protocol: 'vless', settings, streamSettings: { network: 'tcp', security: 'none' }, sniffing: {}, port: RandomUtil.randomInteger(10000, 60000), listen: '', tag: '', enable: true, trafficReset: 'never', }); } export default function InboundFormModalNew({ open, onClose, onSaved, mode, dbInbound, availableNodes, }: InboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); const selectableNodes = (availableNodes || []).filter((n) => n.enable); const protocol = Form.useWatch('protocol', form) ?? ''; const isNodeEligible = NODE_ELIGIBLE_PROTOCOLS.has(protocol); const sniffingEnabled = Form.useWatch(['sniffing', 'enabled'], form) ?? false; useEffect(() => { if (!open) return; const initial = mode === 'edit' && dbInbound ? rawInboundToFormValues(dbInbound) : buildAddModeValues(); form.resetFields(); form.setFieldsValue(initial); }, [open, mode, dbInbound, form]); // Why: protocol picker reset cascades through the form — clearing the // settings DU branch and dropping a nodeId that no longer applies. The // legacy modal did this imperatively in onProtocolChange; here we hook // into AntD's onValuesChange and let setFieldValue keep the rest of // the form state intact. const onValuesChange = (changed: Partial) => { if (mode === 'edit') return; if ('protocol' in changed && typeof changed.protocol === 'string') { const next = changed.protocol; const settings = createDefaultInboundSettings(next) ?? undefined; form.setFieldValue('settings', settings); if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { form.setFieldValue('nodeId', null); } } }; const submit = async () => { let values: InboundFormValues; try { values = await form.validateFields(); } catch { return; } const parsed = InboundFormSchema.safeParse(values); if (!parsed.success) { const issue = parsed.error.issues[0]; messageApi.error( t(issue?.message ?? 'somethingWentWrong', { defaultValue: issue?.message ?? 'invalid', }), ); return; } setSaving(true); try { const payload = formValuesToWirePayload(parsed.data); const url = mode === 'edit' && dbInbound ? `/panel/api/inbounds/update/${dbInbound.id}` : '/panel/api/inbounds/add'; const msg = await HttpUtil.post(url, payload); if (msg?.success) { onSaved(); onClose(); } } finally { setSaving(false); } }; const title = mode === 'edit' ? t('pages.inbounds.modifyInbound') : t('pages.inbounds.addInbound'); const okText = mode === 'edit' ? t('pages.clients.submitEdit') : t('create'); const basicTab = ( <> {selectableNodes.length > 0 && isNodeEligible && ( )} {t('pages.inbounds.totalFlow')} } > prev.total !== curr.total} > {({ getFieldValue, setFieldValue }) => { const totalBytes = (getFieldValue('total') as number) ?? 0; const totalGB = totalBytes ? Math.round((totalBytes / SizeFormatter.ONE_GB) * 100) / 100 : 0; return ( { const bytes = NumberFormatter.toFixed( (Number(v) || 0) * SizeFormatter.ONE_GB, 0, ); setFieldValue('total', bytes); }} /> ); }} {t('pages.inbounds.expireDate')} } > prev.expiryTime !== curr.expiryTime} > {({ getFieldValue, setFieldValue }) => { const expiry = (getFieldValue('expiryTime') as number) ?? 0; return ( 0 ? dayjs(expiry) : null} onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)} /> ); }} ); const sniffingTab = ( <> {sniffingEnabled && ( <> {Object.entries(SNIFFING_OPTION).map(([key, value]) => ( {key} ))} )} ); return ( <> {messageContextHolder}
); }