From 44f2f426d8aa57c567285fe3720c936ebe8959e2 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 12 Jul 2026 15:39:00 +0200 Subject: [PATCH] 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. --- .../src/components/feedback/TextModal.tsx | 36 +++++++++++++++---- frontend/src/pages/inbounds/InboundsPage.tsx | 29 ++++++++++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/feedback/TextModal.tsx b/frontend/src/components/feedback/TextModal.tsx index 19586fb82..978f445a7 100644 --- a/frontend/src/components/feedback/TextModal.tsx +++ b/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 && ( + ({ key: tab.key, label: tab.label }))} + /> + )} {json ? ( - + ) : ( 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(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} />