Просмотр исходного кода

feat(frontend): split wireguard inbound export into config and links tabs

The per-inbound export modal only showed the joined .conf blocks for wireguard, with no way to grab the wireguard:// share links the QR modal already generates. TextModal gains an optional tabs prop (copy and download follow the active tab), and the wireguard export now offers a Config tab with the .conf blocks alongside a Links tab with the per-client wireguard:// URLs. Tab labels reuse the existing pages.clients.config / pages.clients.tabLinks locale keys. Other protocols keep the single untabbed view.
MHSanaei 8 часов назад
Родитель
Сommit
44f2f426d8

+ 30 - 6
frontend/src/components/feedback/TextModal.tsx

@@ -1,10 +1,17 @@
-import { Button, Input, Modal, message } from 'antd';
+import { useEffect, useState } from 'react';
+import { Button, Input, Modal, Tabs, message } from 'antd';
 import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 
 import JsonEditor from '@/components/form/JsonEditor';
 import { ClipboardManager, FileManager } from '@/utils';
 
+export interface TextModalTab {
+  key: string;
+  label: string;
+  content: string;
+}
+
 interface TextModalProps {
   open: boolean;
   onClose: () => void;
@@ -12,13 +19,23 @@ interface TextModalProps {
   content: string;
   fileName?: string;
   json?: boolean;
+  tabs?: TextModalTab[];
 }
 
-export default function TextModal({ open, onClose, title, content, fileName = '', json = false }: TextModalProps) {
+export default function TextModal({ open, onClose, title, content, fileName = '', json = false, tabs }: TextModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
+  const [activeKey, setActiveKey] = useState('');
+
+  useEffect(() => {
+    if (open && tabs && tabs.length > 0) setActiveKey(tabs[0].key);
+  }, [open, tabs]);
+
+  const activeTab = tabs?.find((tab) => tab.key === activeKey) ?? tabs?.[0];
+  const activeContent = activeTab ? activeTab.content : content;
+
   async function copy() {
-    const ok = await ClipboardManager.copyText(content || '');
+    const ok = await ClipboardManager.copyText(activeContent || '');
     if (ok) {
       messageApi.success(t('copied'));
       onClose();
@@ -27,7 +44,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
 
   function download() {
     if (!fileName) return;
-    FileManager.downloadTextFile(content, fileName);
+    FileManager.downloadTextFile(activeContent, fileName);
   }
 
   return (
@@ -47,11 +64,18 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
         </>
       )}
     >
+      {tabs && tabs.length > 0 && (
+        <Tabs
+          activeKey={activeTab?.key}
+          onChange={setActiveKey}
+          items={tabs.map((tab) => ({ key: tab.key, label: tab.label }))}
+        />
+      )}
       {json ? (
-        <JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
+        <JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
       ) : (
         <Input.TextArea
-          value={content}
+          value={activeContent}
           readOnly
           autoSize={{ minRows: 10, maxRows: 20 }}
           style={{

+ 21 - 8
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -24,7 +24,7 @@ import {
 
 import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
 import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
-import { genInboundLinks, preferPublicHost } from '@/lib/xray/inbound-link';
+import { genInboundLinks, genWireguardLinks, preferPublicHost } from '@/lib/xray/inbound-link';
 import { inboundFromDb } from '@/lib/xray/inbound-from-db';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 import { useTheme } from '@/hooks/useTheme';
@@ -33,6 +33,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import AppSidebar from '@/layouts/AppSidebar';
 const TextModal = lazy(() => import('@/components/feedback/TextModal'));
+import type { TextModalTab } from '@/components/feedback/TextModal';
 const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
 
 import { useInbounds } from './useInbounds';
@@ -148,6 +149,7 @@ export default function InboundsPage() {
   const [textContent, setTextContent] = useState('');
   const [textFileName, setTextFileName] = useState('');
   const [textJson, setTextJson] = useState(false);
+  const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
 
   const [promptOpen, setPromptOpen] = useState(false);
   const [promptTitle, setPromptTitle] = useState('');
@@ -166,11 +168,12 @@ export default function InboundsPage() {
   const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
   const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
 
-  const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean }) => {
+  const openText = useCallback((opts: { title: string; content: string; fileName?: string; json?: boolean; tabs?: TextModalTab[] }) => {
     setTextTitle(opts.title);
     setTextContent(opts.content);
     setTextFileName(opts.fileName || '');
     setTextJson(opts.json || false);
+    setTextTabs(opts.tabs);
     setTextOpen(true);
   }, []);
 
@@ -259,15 +262,24 @@ export default function InboundsPage() {
 
   const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
     const projected = checkFallback(dbInbound);
+    const genInput = {
+      inbound: inboundFromDb(projected),
+      remark: projected.remark,
+      hostOverride: hostOverrideFor(dbInbound),
+      fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
+    };
+    const content = genInboundLinks(genInput);
+    const tabs: TextModalTab[] | undefined = projected.isWireguard
+      ? [
+        { key: 'config', label: t('pages.clients.config'), content },
+        { key: 'links', label: t('pages.clients.tabLinks'), content: genWireguardLinks(genInput) },
+      ]
+      : undefined;
     openText({
       title: t('pages.inbounds.exportLinksTitle'),
-      content: genInboundLinks({
-        inbound: inboundFromDb(projected),
-        remark: projected.remark,
-        hostOverride: hostOverrideFor(dbInbound),
-        fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
-      }),
+      content,
       fileName: projected.remark || 'inbound',
+      tabs,
     });
   }, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
 
@@ -706,6 +718,7 @@ export default function InboundsPage() {
             content={textContent}
             fileName={textFileName}
             json={textJson}
+            tabs={textTabs}
           />
         </LazyMount>
         <LazyMount when={promptOpen}>