From bf7074358931bf3d707b6f701af7ee1cb39fddca Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 26 May 2026 02:05:03 +0200 Subject: [PATCH] feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First real section of the sibling-file rewrite. Wires AntD Form.Items to InboundFormValues paths for the basic tab — enable, remark, deployTo (when protocol is node-eligible), protocol, listen, port, totalGB, trafficReset, expireDate. The port input gets a per-field antdRule against InboundFormBaseSchema.shape.port — the spec's Pattern A reference. The intersection-typed InboundFormSchema has no .shape accessor, so per-field rules pull from the underlying ZodObject components. totalGB and expireDate are bytes/timestamp on the wire but a GB number / dayjs picker in the UI. Both use shouldUpdate-closure children that read form state and call setFieldValue on user input — no transient form-only fields, no DU-shape surprises at submit time. Protocol-change cascade lives in Form's onValuesChange: pick a new protocol and the settings DU branch is reset to createDefaultInboundSettings(next); a non-node-eligible protocol also clears nodeId. Modal still renders a single-tab Tabs container. Sniffing tab is next. --- .../pages/inbounds/InboundFormModal.new.tsx | 191 ++++++++++++++++-- 1 file changed, 175 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundFormModal.new.tsx b/frontend/src/pages/inbounds/InboundFormModal.new.tsx index 030c287a..43dc3484 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.new.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.new.tsx @@ -1,29 +1,50 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Form, Modal, Typography, message } from 'antd'; +import dayjs from 'dayjs'; +import { + Form, + Input, + InputNumber, + Modal, + Select, + Switch, + Tabs, + Tooltip, + message, +} from 'antd'; -import { HttpUtil, RandomUtil } from '@/utils'; +import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter } from '@/utils'; import { rawInboundToFormValues, formValuesToWirePayload, } from '@/lib/xray/inbound-form-adapter'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; -import { InboundFormSchema, type InboundFormValues } from '@/schemas/forms/inbound-form'; +import { + InboundFormBaseSchema, + InboundFormSchema, + type InboundFormValues, +} from '@/schemas/forms/inbound-form'; +import { antdRule } from '@/utils/zodForm'; +import { Protocols } 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. The -// old InboundFormModal.tsx continues to be the one InboundsPage renders -// until the atomic swap at the end of the rewrite (per Core Decision 7 in -// the architecture spec). -// -// Current state: skeleton only. The form holds the full InboundFormValues -// shape via setFieldsValue on open; validateFields + safeParse + adapter -// produce the wire payload on submit. Tabs are not yet wired — the modal -// body shows a WIP placeholder. +// 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 { Text } = Typography; +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; @@ -56,20 +77,43 @@ export default function InboundFormModalNew({ 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); + 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 { @@ -111,6 +155,122 @@ export default function InboundFormModalNew({ ? 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)} + /> + ); + }} + + + + ); + return ( <> {messageContextHolder} @@ -131,10 +291,9 @@ export default function InboundFormModalNew({ colon={false} labelCol={{ sm: { span: 8 } }} wrapperCol={{ sm: { span: 14 } }} + onValuesChange={onValuesChange} > - - WIP — Pattern A rewrite. Tabs are not yet wired into this skeleton. - +