mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +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:
parent
b10e0d0acd
commit
bf70743589
1 changed files with 175 additions and 16 deletions
|
|
@ -1,29 +1,50 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
import {
|
||||||
rawInboundToFormValues,
|
rawInboundToFormValues,
|
||||||
formValuesToWirePayload,
|
formValuesToWirePayload,
|
||||||
} from '@/lib/xray/inbound-form-adapter';
|
} from '@/lib/xray/inbound-form-adapter';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
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 { DBInbound } from '@/models/dbinbound';
|
||||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||||
|
|
||||||
// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
|
// Pattern A rewrite of InboundFormModal. Built as a sibling file so the
|
||||||
// build stays green while the rewrite progresses section by section. The
|
// build stays green while the rewrite progresses section by section.
|
||||||
// old InboundFormModal.tsx continues to be the one InboundsPage renders
|
// InboundsPage continues to render the old InboundFormModal.tsx until the
|
||||||
// until the atomic swap at the end of the rewrite (per Core Decision 7 in
|
// atomic swap at the end (Core Decision 7).
|
||||||
// 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.
|
|
||||||
|
|
||||||
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 {
|
interface InboundFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -56,20 +77,43 @@ export default function InboundFormModalNew({
|
||||||
onSaved,
|
onSaved,
|
||||||
mode,
|
mode,
|
||||||
dbInbound,
|
dbInbound,
|
||||||
|
availableNodes,
|
||||||
}: InboundFormModalProps) {
|
}: InboundFormModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [form] = Form.useForm<InboundFormValues>();
|
const [form] = Form.useForm<InboundFormValues>();
|
||||||
const [saving, setSaving] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const initial = mode === 'edit' && dbInbound
|
const initial = mode === 'edit' && dbInbound
|
||||||
? rawInboundToFormValues(dbInbound)
|
? rawInboundToFormValues(dbInbound)
|
||||||
: buildAddModeValues();
|
: buildAddModeValues();
|
||||||
|
form.resetFields();
|
||||||
form.setFieldsValue(initial);
|
form.setFieldsValue(initial);
|
||||||
}, [open, mode, dbInbound, form]);
|
}, [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 () => {
|
const submit = async () => {
|
||||||
let values: InboundFormValues;
|
let values: InboundFormValues;
|
||||||
try {
|
try {
|
||||||
|
|
@ -111,6 +155,122 @@ export default function InboundFormModalNew({
|
||||||
? t('pages.clients.submitEdit')
|
? t('pages.clients.submitEdit')
|
||||||
: t('create');
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{messageContextHolder}
|
{messageContextHolder}
|
||||||
|
|
@ -131,10 +291,9 @@ export default function InboundFormModalNew({
|
||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ sm: { span: 8 } }}
|
labelCol={{ sm: { span: 8 } }}
|
||||||
wrapperCol={{ sm: { span: 14 } }}
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Text type="secondary">
|
<Tabs items={[{ key: 'basic', label: t('pages.xray.basicTemplate'), children: basicTab }]} />
|
||||||
WIP — Pattern A rewrite. Tabs are not yet wired into this skeleton.
|
|
||||||
</Text>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue