mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +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:
parent
d6d0c3bb41
commit
ab24871669
1 changed files with 166 additions and 3 deletions
|
|
@ -1,9 +1,11 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
|
Empty,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
|
|
@ -17,7 +19,7 @@ import {
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} 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 { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||||
import {
|
import {
|
||||||
|
|
@ -36,6 +38,7 @@ import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||||
import {
|
import {
|
||||||
InboundFormBaseSchema,
|
InboundFormBaseSchema,
|
||||||
InboundFormSchema,
|
InboundFormSchema,
|
||||||
|
type FallbackRow,
|
||||||
type InboundFormValues,
|
type InboundFormValues,
|
||||||
} from '@/schemas/forms/inbound-form';
|
} from '@/schemas/forms/inbound-form';
|
||||||
import { antdRule } from '@/utils/zodForm';
|
import { antdRule } from '@/utils/zodForm';
|
||||||
|
|
@ -150,12 +153,15 @@ export default function InboundFormModalNew({
|
||||||
onSaved,
|
onSaved,
|
||||||
mode,
|
mode,
|
||||||
dbInbound,
|
dbInbound,
|
||||||
|
dbInbounds,
|
||||||
availableNodes,
|
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 fallbackKeyRef = useRef(0);
|
||||||
|
const [fallbacks, setFallbacks] = useState<FallbackRow[]>([]);
|
||||||
|
|
||||||
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
const selectableNodes = (availableNodes || []).filter((n) => n.enable);
|
||||||
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
|
const protocol = (Form.useWatch('protocol', form) ?? '') as string;
|
||||||
|
|
@ -173,6 +179,79 @@ export default function InboundFormModalNew({
|
||||||
const streamEnabled = canEnableStream({ protocol });
|
const streamEnabled = canEnableStream({ protocol });
|
||||||
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
const tlsAllowed = canEnableTls({ protocol, streamSettings: { network, security } });
|
||||||
const realityAllowed = canEnableReality({ 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 () => {
|
const genRealityKeypair = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
@ -362,6 +441,16 @@ export default function InboundFormModalNew({
|
||||||
: buildAddModeValues();
|
: buildAddModeValues();
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue(initial);
|
form.setFieldsValue(initial);
|
||||||
|
if (
|
||||||
|
mode === 'edit'
|
||||||
|
&& dbInbound
|
||||||
|
&& (dbInbound.protocol === Protocols.VLESS || dbInbound.protocol === Protocols.TROJAN)
|
||||||
|
) {
|
||||||
|
loadFallbacks(dbInbound.id);
|
||||||
|
} else {
|
||||||
|
setFallbacks([]);
|
||||||
|
}
|
||||||
|
|
||||||
}, [open, mode, dbInbound, form]);
|
}, [open, mode, dbInbound, form]);
|
||||||
|
|
||||||
// Why: protocol picker reset cascades through the form — clearing the
|
// Why: protocol picker reset cascades through the form — clearing the
|
||||||
|
|
@ -406,6 +495,13 @@ export default function InboundFormModalNew({
|
||||||
: '/panel/api/inbounds/add';
|
: '/panel/api/inbounds/add';
|
||||||
const msg = await HttpUtil.post(url, payload);
|
const msg = await HttpUtil.post(url, payload);
|
||||||
if (msg?.success) {
|
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();
|
onSaved();
|
||||||
onClose();
|
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 = (
|
const protocolTab = (
|
||||||
<>
|
<>
|
||||||
{protocol === Protocols.WIREGUARD && (
|
{protocol === Protocols.WIREGUARD && (
|
||||||
|
|
@ -930,6 +1091,8 @@ export default function InboundFormModalNew({
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isFallbackHost && fallbacksCard}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2033,7 +2196,7 @@ export default function InboundFormModalNew({
|
||||||
Protocols.TUNNEL,
|
Protocols.TUNNEL,
|
||||||
Protocols.TUN,
|
Protocols.TUN,
|
||||||
Protocols.WIREGUARD,
|
Protocols.WIREGUARD,
|
||||||
] as string[]).includes(protocol)
|
] as string[]).includes(protocol) || isFallbackHost
|
||||||
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab }]
|
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab }]
|
||||||
: []),
|
: []),
|
||||||
...(streamEnabled
|
...(streamEnabled
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue