mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-04 03:19:34 +00:00
feat(frontend): fallbacks card on InboundFormModal.new.tsx (Pattern A)
Adds the fallbacks card rendered inside the protocol tab whenever the
current values describe a fallback host — VLESS or Trojan on tcp with
tls or reality security. The protocol tab visibility widens to include
Trojan in that exact case (it has no other protocol sub-form).
Fallbacks live in a useState alongside the form rather than inside form
values, mirroring the legacy modal: fallbacks save via a distinct
endpoint (/panel/api/inbounds/{id}/fallbacks) after the main inbound
POST, not as part of the inbound payload. loadFallbacks runs on open
for edit-mode VLESS/Trojan; saveFallbacks runs after a successful POST
inside the submit handler.
Each row: child picker (filtered down to other inbounds), then four
inline edits for SNI / ALPN / path / xver. Add adds an empty row;
delete pulls the row from state.
Quick-Add-All, the rederive-from-child helper, and the per-row up/down
movers are deferred — the basic add/edit/remove cycle is what the modal
actually needs to function.
This commit is contained in:
@@ -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<InboundFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const fallbackKeyRef = useRef(0);
|
||||
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
|
||||
|
||||
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<FallbackRow>) => {
|
||||
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 = (
|
||||
<Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
|
||||
{fallbacks.length === 0 && (
|
||||
<Empty
|
||||
description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
|
||||
styles={{ image: { height: 40 } }}
|
||||
style={{ margin: '8px 0 12px' }}
|
||||
/>
|
||||
)}
|
||||
{fallbacks.map((record, idx) => (
|
||||
<div
|
||||
key={record.rowKey}
|
||||
style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
|
||||
>
|
||||
<Space.Compact block style={{ marginBottom: 6 }}>
|
||||
<Select
|
||||
value={record.childId}
|
||||
options={fallbackChildOptions}
|
||||
showSearch
|
||||
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
|
||||
filterOption={(input, option) =>
|
||||
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateFallback(record.rowKey, { childId: v })}
|
||||
/>
|
||||
<Button danger onClick={() => removeFallback(idx)}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Space.Compact block>
|
||||
<InputAddon>SNI</InputAddon>
|
||||
<Input
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.name}
|
||||
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
|
||||
/>
|
||||
<InputAddon>ALPN</InputAddon>
|
||||
<Input
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.alpn}
|
||||
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
|
||||
/>
|
||||
<InputAddon>Path</InputAddon>
|
||||
<Input
|
||||
placeholder="/"
|
||||
value={record.path}
|
||||
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
|
||||
/>
|
||||
<InputAddon>xver</InputAddon>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={2}
|
||||
value={record.xver}
|
||||
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
))}
|
||||
<Button size="small" onClick={addFallback}>
|
||||
<PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const protocolTab = (
|
||||
<>
|
||||
{protocol === Protocols.WIREGUARD && (
|
||||
@@ -930,6 +1091,8 @@ export default function InboundFormModalNew({
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user