|
@@ -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
|
|
|
|
|
-// 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.
|
|
|
|
|
-
|
|
|
|
|
-const { Text } = Typography;
|
|
|
|
|
|
|
+// 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<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">
|
|
|
|
|
- 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>
|
|
</Form>
|
|
|
</Modal>
|
|
</Modal>
|
|
|
</>
|
|
</>
|