diff --git a/frontend/src/pages/inbounds/InboundFormModal.new.tsx b/frontend/src/pages/inbounds/InboundFormModal.new.tsx index 2e9c32e7..5fe915ef 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.new.tsx +++ b/frontend/src/pages/inbounds/InboundFormModal.new.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { Button, + Card, Checkbox, + Empty, Form, Input, InputNumber, @@ -17,7 +19,7 @@ import { Typography, message, } from 'antd'; -import { MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'; +import { DeleteOutlined, MinusOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'; import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils'; import { @@ -36,6 +38,7 @@ import { getRandomRealityTarget } from '@/models/reality-targets'; import { InboundFormBaseSchema, InboundFormSchema, + type FallbackRow, type InboundFormValues, } from '@/schemas/forms/inbound-form'; import { antdRule } from '@/utils/zodForm'; @@ -150,12 +153,15 @@ export default function InboundFormModalNew({ onSaved, mode, dbInbound, + dbInbounds, availableNodes, }: InboundFormModalProps) { const { t } = useTranslation(); const [messageApi, messageContextHolder] = message.useMessage(); const [form] = Form.useForm(); const [saving, setSaving] = useState(false); + const fallbackKeyRef = useRef(0); + const [fallbacks, setFallbacks] = useState([]); const selectableNodes = (availableNodes || []).filter((n) => n.enable); const protocol = (Form.useWatch('protocol', form) ?? '') as string; @@ -173,6 +179,79 @@ export default function InboundFormModalNew({ const streamEnabled = canEnableStream({ protocol }); const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } }); const realityAllowed = canEnableReality({ protocol, streamSettings: { network, security } }); + const isFallbackHost = + (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) + && network === 'tcp' + && (security === 'tls' || security === 'reality'); + + const fallbackChildOptions = (dbInbounds || []) + .filter((ib) => ib.id !== dbInbound?.id) + .map((ib) => ({ + label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`, + value: ib.id, + })); + + const loadFallbacks = async (masterId: number | null) => { + if (!masterId) { + setFallbacks([]); + return; + } + const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`); + if (!msg?.success || !Array.isArray(msg.obj)) { + setFallbacks([]); + return; + } + setFallbacks( + (msg.obj as { childId: number; name?: string; alpn?: string; path?: string; xver?: number }[]) + .map((r) => ({ + rowKey: `fb-${++fallbackKeyRef.current}`, + childId: r.childId, + name: r.name || '', + alpn: r.alpn || '', + path: r.path || '', + xver: r.xver || 0, + })), + ); + }; + + const saveFallbacks = async (masterId: number) => { + if (!masterId) return true; + const payload = { + fallbacks: fallbacks.filter((c) => c.childId).map((c, i) => ({ + childId: c.childId, + name: c.name, + alpn: c.alpn, + path: c.path, + xver: Number(c.xver) || 0, + sortOrder: i, + })), + }; + const msg = await HttpUtil.post( + `/panel/api/inbounds/${masterId}/fallbacks`, + payload, + { headers: { 'Content-Type': 'application/json' } }, + ); + return !!msg?.success; + }; + + const addFallback = () => { + setFallbacks((prev) => [...prev, { + rowKey: `fb-${++fallbackKeyRef.current}`, + childId: null, + name: '', + alpn: '', + path: '', + xver: 0, + }]); + }; + + const updateFallback = (rowKey: string, patch: Partial) => { + setFallbacks((prev) => prev.map((r) => r.rowKey === rowKey ? { ...r, ...patch } : r)); + }; + + const removeFallback = (idx: number) => { + setFallbacks((prev) => prev.filter((_, i) => i !== idx)); + }; const genRealityKeypair = async () => { setSaving(true); @@ -362,6 +441,16 @@ export default function InboundFormModalNew({ : buildAddModeValues(); form.resetFields(); form.setFieldsValue(initial); + if ( + mode === 'edit' + && dbInbound + && (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN) + ) { + loadFallbacks(dbInbound.id); + } else { + setFallbacks([]); + } + }, [open, mode, dbInbound, form]); // Why: protocol picker reset cascades through the form — clearing the @@ -406,6 +495,13 @@ export default function InboundFormModalNew({ : '/panel/api/inbounds/add'; const msg = await HttpUtil.post(url, payload); if (msg?.success) { + if (isFallbackHost) { + const obj = msg.obj as { id?: number; Id?: number } | null; + const masterId = mode === 'edit' + ? dbInbound!.id + : (obj?.id ?? obj?.Id ?? 0); + if (masterId) await saveFallbacks(masterId); + } onSaved(); onClose(); } @@ -538,6 +634,71 @@ export default function InboundFormModalNew({ ); + const fallbacksCard = ( + + {fallbacks.length === 0 && ( + + )} + {fallbacks.map((record, idx) => ( +
+ + updateFallback(record.rowKey, { name: e.target.value })} + /> + ALPN + updateFallback(record.rowKey, { alpn: e.target.value })} + /> + Path + updateFallback(record.rowKey, { path: e.target.value })} + /> + xver + updateFallback(record.rowKey, { xver: Number(v) || 0 })} + /> + +
+ ))} + +
+ ); + const protocolTab = ( <> {protocol === Protocols.WIREGUARD && ( @@ -930,6 +1091,8 @@ export default function InboundFormModalNew({ )} + + {isFallbackHost && fallbacksCard} ); @@ -2033,7 +2196,7 @@ export default function InboundFormModalNew({ Protocols.TUNNEL, Protocols.TUN, Protocols.WIREGUARD, - ] as string[]).includes(protocol) + ] as string[]).includes(protocol) || isFallbackHost ? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab }] : []), ...(streamEnabled