ソースを参照

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.
MHSanaei 11 時間 前
コミット
ab24871669
1 ファイル変更166 行追加3 行削除
  1. 166 3
      frontend/src/pages/inbounds/InboundFormModal.new.tsx

+ 166 - 3
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<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