diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts index f6aafdf3..e6883ecf 100644 --- a/frontend/src/lib/xray/outbound-form-adapter.ts +++ b/frontend/src/lib/xray/outbound-form-adapter.ts @@ -358,7 +358,16 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues const tag = asString(raw.tag); const sendThrough = asString(raw.sendThrough); 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; switch (protocol) { diff --git a/frontend/src/pages/xray/OutboundFormModal.new.tsx b/frontend/src/pages/xray/OutboundFormModal.new.tsx new file mode 100644 index 00000000..a3e4f824 --- /dev/null +++ b/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 | null; + existingTags: string[]; + onClose: () => void; + onConfirm: (outbound: Record) => 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(); + 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; + try { + parsed = JSON.parse(raw) as Record; + } 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} + +
+ + + + + + + + + + {/* Protocol-specific sub-forms come in subsequent commits. */} +
+ Protocol-specific fields for {protocol} are still being + migrated. Use the JSON tab to edit settings until the + relevant section lands. +
+ + ), + }, + { + key: '2', + label: 'JSON', + children: ( + + { + setJsonText(next); + setJsonDirty(true); + }} + minHeight="360px" + maxHeight="600px" + /> + + ), + }, + ]} + /> + +
+ + ); +}