Sfoglia il codice sorgente

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.
MHSanaei 20 ore fa
parent
commit
bf70743589
1 ha cambiato i file con 176 aggiunte e 17 eliminazioni
  1. 176 17
      frontend/src/pages/inbounds/InboundFormModal.new.tsx

+ 176 - 17
frontend/src/pages/inbounds/InboundFormModal.new.tsx

@@ -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>
     </>
     </>