Просмотр исходного кода

feat(frontend): OutboundFormModal.new.tsx skeleton (Pattern A)

Sibling .new.tsx file with the Modal shell, Tabs (Basic/JSON), Form.useForm
hydration via rawOutboundToFormValues, and the submit pipeline that calls
formValuesToWirePayload before onConfirm. Tag uniqueness check is wired in.

Protocol-specific sub-forms, stream, security, sockopt, and mux sections
are deferred to subsequent commits — accessible via the JSON tab in the
meantime. The InboundsPage continues to render the legacy modal until the
atomic swap at the end.

Also: rawOutboundToFormValues now returns streamSettings as undefined
when the wire payload omits it, so Form.useForm doesn't receive a value
that does not match the NetworkSettings discriminated union.
MHSanaei 10 часов назад
Родитель
Сommit
e64d1a9bef
2 измененных файлов с 224 добавлено и 1 удалено
  1. 10 1
      frontend/src/lib/xray/outbound-form-adapter.ts
  2. 214 0
      frontend/src/pages/xray/OutboundFormModal.new.tsx

+ 10 - 1
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -358,7 +358,16 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
   const tag = asString(raw.tag);
   const tag = asString(raw.tag);
   const sendThrough = asString(raw.sendThrough);
   const sendThrough = asString(raw.sendThrough);
   const mux = muxFromWire(raw.mux);
   const mux = muxFromWire(raw.mux);
-  const streamSettings = asObject(raw.streamSettings) as unknown as OutboundStreamFormValues | undefined;
+  // Leave streamSettings undefined when missing or empty — the modal's
+  // stream tab seeds it when the user opens the relevant section. This
+  // keeps Form.useForm from receiving a value that doesn't match the
+  // NetworkSettings DU.
+  const hasStream = raw.streamSettings
+    && typeof raw.streamSettings === 'object'
+    && Object.keys(raw.streamSettings as Raw).length > 0;
+  const streamSettings = hasStream
+    ? (raw.streamSettings as unknown as OutboundStreamFormValues)
+    : undefined;
 
 
   let typed: OutboundFormSettings;
   let typed: OutboundFormSettings;
   switch (protocol) {
   switch (protocol) {

+ 214 - 0
frontend/src/pages/xray/OutboundFormModal.new.tsx

@@ -0,0 +1,214 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Form, Input, Modal, Select, Space, Tabs, message } from 'antd';
+
+import JsonEditor from '@/components/JsonEditor';
+import {
+  formValuesToWirePayload,
+  rawOutboundToFormValues,
+} from '@/lib/xray/outbound-form-adapter';
+import { OutboundFormBaseSchema, type OutboundFormValues } from '@/schemas/forms/outbound-form';
+import { OutboundProtocols as Protocols } from '@/schemas/primitives';
+import { antdRule } from '@/utils/zodForm';
+import './OutboundFormModal.css';
+
+// Pattern A rewrite of OutboundFormModal. Built as a sibling `.new.tsx`
+// file so the build stays green section-by-section. The atomic swap at
+// the end of the rewrite replaces the legacy file in one commit
+// (per Core Decision 7 in the migration spec).
+
+interface OutboundFormModalProps {
+  open: boolean;
+  outbound: Record<string, unknown> | null;
+  existingTags: string[];
+  onClose: () => void;
+  onConfirm: (outbound: Record<string, unknown>) => void;
+}
+
+const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
+
+function buildAddModeValues(): OutboundFormValues {
+  return rawOutboundToFormValues({});
+}
+
+export default function OutboundFormModalNew({
+  open,
+  outbound: outboundProp,
+  existingTags,
+  onClose,
+  onConfirm,
+}: OutboundFormModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [form] = Form.useForm<OutboundFormValues>();
+  const [activeKey, setActiveKey] = useState('1');
+  const [jsonText, setJsonText] = useState('');
+  const [jsonDirty, setJsonDirty] = useState(false);
+
+  const isEdit = outboundProp != null;
+  const title = isEdit
+    ? `${t('edit')} ${t('pages.xray.Outbounds')}`
+    : `+ ${t('pages.xray.Outbounds')}`;
+  const okText = isEdit ? t('pages.clients.submitEdit') : t('create');
+
+  useEffect(() => {
+    if (!open) return;
+    const initial = outboundProp
+      ? rawOutboundToFormValues(outboundProp)
+      : buildAddModeValues();
+    form.resetFields();
+    form.setFieldsValue(initial);
+    setActiveKey('1');
+    setJsonText(JSON.stringify(formValuesToWirePayload(initial), null, 2));
+    setJsonDirty(false);
+  }, [open, outboundProp, form]);
+
+  const tag = Form.useWatch('tag', form) ?? '';
+  const protocol = (Form.useWatch('protocol', form) ?? 'vless') as string;
+
+  const duplicateTag = useMemo(() => {
+    const myTag = tag.trim();
+    if (!myTag) return false;
+    if (isEdit && (outboundProp?.tag as string | undefined) === myTag) return false;
+    return (existingTags || []).includes(myTag);
+  }, [tag, existingTags, isEdit, outboundProp]);
+
+  // Bridge form ↔ JSON tab: when leaving the JSON tab back to Basic, push
+  // any edits into form state. When entering JSON tab, snapshot current
+  // form values so the user sees the live shape.
+  function applyJsonToForm(): boolean {
+    if (!jsonDirty) return true;
+    const raw = jsonText.trim();
+    if (!raw) return true;
+    let parsed: Record<string, unknown>;
+    try {
+      parsed = JSON.parse(raw) as Record<string, unknown>;
+    } catch (e) {
+      messageApi.error(`JSON: ${(e as Error).message}`);
+      return false;
+    }
+    const next = rawOutboundToFormValues(parsed);
+    form.resetFields();
+    form.setFieldsValue(next);
+    setJsonDirty(false);
+    return true;
+  }
+
+  function onTabChange(key: string) {
+    if (document.activeElement instanceof HTMLElement) {
+      document.activeElement.blur();
+    }
+    if (key === '2') {
+      const values = form.getFieldsValue(true) as OutboundFormValues;
+      setJsonText(JSON.stringify(formValuesToWirePayload(values), null, 2));
+      setJsonDirty(false);
+      setActiveKey(key);
+      return;
+    }
+    if (key === '1' && activeKey === '2') {
+      if (!applyJsonToForm()) return;
+    }
+    setActiveKey(key);
+  }
+
+  async function onOk() {
+    if (activeKey === '2' && !applyJsonToForm()) return;
+    let values: OutboundFormValues;
+    try {
+      values = await form.validateFields();
+    } catch {
+      return;
+    }
+    if (duplicateTag) {
+      messageApi.error('Tag already used by another outbound');
+      return;
+    }
+    onConfirm(formValuesToWirePayload(values));
+  }
+
+  return (
+    <>
+      {messageContextHolder}
+      <Modal
+        open={open}
+        title={title}
+        okText={okText}
+        cancelText={t('close')}
+        mask={{ closable: false }}
+        width={780}
+        onOk={onOk}
+        onCancel={onClose}
+        destroyOnHidden
+      >
+        <Form
+          form={form}
+          colon={false}
+          labelCol={{ md: { span: 8 } }}
+          wrapperCol={{ md: { span: 14 } }}
+        >
+          <Tabs
+            activeKey={activeKey}
+            onChange={onTabChange}
+            items={[
+              {
+                key: '1',
+                label: t('pages.xray.basicTemplate'),
+                children: (
+                  <>
+                    <Form.Item
+                      label={t('protocol')}
+                      name="protocol"
+                      rules={[antdRule(OutboundFormBaseSchema.shape.tag, t)]}
+                    >
+                      <Select options={PROTOCOL_OPTIONS} />
+                    </Form.Item>
+
+                    <Form.Item
+                      label="Tag"
+                      name="tag"
+                      validateStatus={duplicateTag ? 'warning' : undefined}
+                      help={duplicateTag ? 'Tag already used by another outbound' : undefined}
+                      rules={[
+                        { required: true, message: 'Tag is required' },
+                      ]}
+                    >
+                      <Input placeholder="unique-tag" />
+                    </Form.Item>
+
+                    <Form.Item label="Send through" name="sendThrough">
+                      <Input placeholder="local IP" />
+                    </Form.Item>
+
+                    {/* Protocol-specific sub-forms come in subsequent commits. */}
+                    <div style={{ marginTop: 12, opacity: 0.6, fontStyle: 'italic' }}>
+                      Protocol-specific fields for {protocol} are still being
+                      migrated. Use the JSON tab to edit settings until the
+                      relevant section lands.
+                    </div>
+                  </>
+                ),
+              },
+              {
+                key: '2',
+                label: 'JSON',
+                children: (
+                  <Space orientation="vertical" size={10} style={{ width: '100%', marginTop: 10 }}>
+                    <JsonEditor
+                      value={jsonText}
+                      onChange={(next) => {
+                        setJsonText(next);
+                        setJsonDirty(true);
+                      }}
+                      minHeight="360px"
+                      maxHeight="600px"
+                    />
+                  </Space>
+                ),
+              },
+            ]}
+          />
+        </Form>
+      </Modal>
+    </>
+  );
+}