mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 14:07:13 +00:00
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.
This commit is contained in:
@@ -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 { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import JsonEditor from '@/components/form/JsonEditor';
|
import JsonEditor from '@/components/form/JsonEditor';
|
||||||
import { ClipboardManager, FileManager } from '@/utils';
|
import { ClipboardManager, FileManager } from '@/utils';
|
||||||
|
|
||||||
|
export interface TextModalTab {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface TextModalProps {
|
interface TextModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -12,13 +19,23 @@ interface TextModalProps {
|
|||||||
content: string;
|
content: string;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
json?: boolean;
|
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 { t } = useTranslation();
|
||||||
const [messageApi, messageContextHolder] = message.useMessage();
|
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() {
|
async function copy() {
|
||||||
const ok = await ClipboardManager.copyText(content || '');
|
const ok = await ClipboardManager.copyText(activeContent || '');
|
||||||
if (ok) {
|
if (ok) {
|
||||||
messageApi.success(t('copied'));
|
messageApi.success(t('copied'));
|
||||||
onClose();
|
onClose();
|
||||||
@@ -27,7 +44,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
|
|||||||
|
|
||||||
function download() {
|
function download() {
|
||||||
if (!fileName) return;
|
if (!fileName) return;
|
||||||
FileManager.downloadTextFile(content, fileName);
|
FileManager.downloadTextFile(activeContent, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 ? (
|
{json ? (
|
||||||
<JsonEditor value={content} readOnly minHeight="240px" maxHeight="60vh" />
|
<JsonEditor value={activeContent} readOnly minHeight="240px" maxHeight="60vh" />
|
||||||
) : (
|
) : (
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={content}
|
value={activeContent}
|
||||||
readOnly
|
readOnly
|
||||||
autoSize={{ minRows: 10, maxRows: 20 }}
|
autoSize={{ minRows: 10, maxRows: 20 }}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
|
|
||||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||||
import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults';
|
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 { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
@@ -33,6 +33,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
|
|||||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
const TextModal = lazy(() => import('@/components/feedback/TextModal'));
|
||||||
|
import type { TextModalTab } from '@/components/feedback/TextModal';
|
||||||
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
|
||||||
|
|
||||||
import { useInbounds } from './useInbounds';
|
import { useInbounds } from './useInbounds';
|
||||||
@@ -148,6 +149,7 @@ export default function InboundsPage() {
|
|||||||
const [textContent, setTextContent] = useState('');
|
const [textContent, setTextContent] = useState('');
|
||||||
const [textFileName, setTextFileName] = useState('');
|
const [textFileName, setTextFileName] = useState('');
|
||||||
const [textJson, setTextJson] = useState(false);
|
const [textJson, setTextJson] = useState(false);
|
||||||
|
const [textTabs, setTextTabs] = useState<TextModalTab[] | undefined>(undefined);
|
||||||
|
|
||||||
const [promptOpen, setPromptOpen] = useState(false);
|
const [promptOpen, setPromptOpen] = useState(false);
|
||||||
const [promptTitle, setPromptTitle] = useState('');
|
const [promptTitle, setPromptTitle] = useState('');
|
||||||
@@ -166,11 +168,12 @@ export default function InboundsPage() {
|
|||||||
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
|
||||||
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, 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);
|
setTextTitle(opts.title);
|
||||||
setTextContent(opts.content);
|
setTextContent(opts.content);
|
||||||
setTextFileName(opts.fileName || '');
|
setTextFileName(opts.fileName || '');
|
||||||
setTextJson(opts.json || false);
|
setTextJson(opts.json || false);
|
||||||
|
setTextTabs(opts.tabs);
|
||||||
setTextOpen(true);
|
setTextOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -259,15 +262,24 @@ export default function InboundsPage() {
|
|||||||
|
|
||||||
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
|
const exportInboundLinks = useCallback((dbInbound: DBInbound) => {
|
||||||
const projected = checkFallback(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({
|
openText({
|
||||||
title: t('pages.inbounds.exportLinksTitle'),
|
title: t('pages.inbounds.exportLinksTitle'),
|
||||||
content: genInboundLinks({
|
content,
|
||||||
inbound: inboundFromDb(projected),
|
|
||||||
remark: projected.remark,
|
|
||||||
hostOverride: hostOverrideFor(dbInbound),
|
|
||||||
fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost),
|
|
||||||
}),
|
|
||||||
fileName: projected.remark || 'inbound',
|
fileName: projected.remark || 'inbound',
|
||||||
|
tabs,
|
||||||
});
|
});
|
||||||
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
}, [checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]);
|
||||||
|
|
||||||
@@ -706,6 +718,7 @@ export default function InboundsPage() {
|
|||||||
content={textContent}
|
content={textContent}
|
||||||
fileName={textFileName}
|
fileName={textFileName}
|
||||||
json={textJson}
|
json={textJson}
|
||||||
|
tabs={textTabs}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={promptOpen}>
|
<LazyMount when={promptOpen}>
|
||||||
|
|||||||
Reference in New Issue
Block a user