mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-29 08:29:34 +00:00
feat(frontend): basic tab on InboundFormModal.new.tsx (Pattern A)
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.
This commit is contained in:
@@ -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<string>([
|
||||
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<InboundFormValues>();
|
||||
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<InboundFormValues>) => {
|
||||
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 = (
|
||||
<>
|
||||
<Form.Item name="enable" label={t('enable')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="remark" label={t('pages.inbounds.remark')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{selectableNodes.length > 0 && isNodeEligible && (
|
||||
<Form.Item name="nodeId" label={t('pages.inbounds.deployTo')}>
|
||||
<Select
|
||||
disabled={mode === 'edit'}
|
||||
placeholder={t('pages.inbounds.localPanel')}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value={null}>{t('pages.inbounds.localPanel')}</Select.Option>
|
||||
{selectableNodes.map((n) => (
|
||||
<Select.Option
|
||||
key={n.id}
|
||||
value={n.id}
|
||||
disabled={n.status === 'offline'}
|
||||
>
|
||||
{n.name}{n.status === 'offline' ? ' (offline)' : ''}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item name="protocol" label={t('pages.inbounds.protocol')}>
|
||||
<Select disabled={mode === 'edit'} options={PROTOCOL_OPTIONS} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="listen" label={t('pages.inbounds.address')}>
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="port"
|
||||
label={t('pages.inbounds.port')}
|
||||
rules={[antdRule(InboundFormBaseSchema.shape.port, t)]}
|
||||
>
|
||||
<InputNumber min={1} max={65535} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.meansNoLimit')}>
|
||||
{t('pages.inbounds.totalFlow')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => 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 (
|
||||
<InputNumber
|
||||
value={totalGB}
|
||||
min={0}
|
||||
step={1}
|
||||
onChange={(v) => {
|
||||
const bytes = NumberFormatter.toFixed(
|
||||
(Number(v) || 0) * SizeFormatter.ONE_GB,
|
||||
0,
|
||||
);
|
||||
setFieldValue('total', bytes);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<Select>
|
||||
{TRAFFIC_RESETS.map((r) => (
|
||||
<Select.Option key={r} value={r}>
|
||||
{t(`pages.inbounds.periodicTrafficReset.${r}`)}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
||||
{t('pages.inbounds.expireDate')}
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.expiryTime !== curr.expiryTime}
|
||||
>
|
||||
{({ getFieldValue, setFieldValue }) => {
|
||||
const expiry = (getFieldValue('expiryTime') as number) ?? 0;
|
||||
return (
|
||||
<DateTimePicker
|
||||
value={expiry > 0 ? dayjs(expiry) : null}
|
||||
onChange={(d) => setFieldValue('expiryTime', d ? d.valueOf() : 0)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
@@ -131,10 +291,9 @@ export default function InboundFormModalNew({
|
||||
colon={false}
|
||||
labelCol={{ sm: { span: 8 } }}
|
||||
wrapperCol={{ sm: { span: 14 } }}
|
||||
onValuesChange={onValuesChange}
|
||||
>
|
||||
<Text type="secondary">
|
||||
WIP — Pattern A rewrite. Tabs are not yet wired into this skeleton.
|
||||
</Text>
|
||||
<Tabs items={[{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab }]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user