feat(settings): sidebar submenu nav for settings and xray with icon tabs

Settings and Xray Configs are now expandable sidebar submenus that list their sections; clicking a section opens it via the URL hash (e.g. #general, #basic) and the in-page top tab bar is removed on both pages.

Within each section the collapse groups become horizontal tabs, each with an icon; on mobile only the icon shows with the label in a tooltip, via a shared catTabLabel helper used by both settings and xray.

Subscription Formats: the nested collapses in Fragment/Noises/Mux/Direct are replaced with a cleaner layout - framed field groups, and each noise is a card with a delete button plus a dashed add button.

Xray: the Reset to Default button is now a solid danger button so its hover state is visible.
This commit is contained in:
MHSanaei
2026-06-03 09:26:25 +02:00
parent e63cde8fcb
commit ac89ec724f
12 changed files with 400 additions and 458 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -6,21 +6,28 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd';
import {
ApiOutlined,
ClusterOutlined,
CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined,
DatabaseOutlined,
GithubOutlined,
HeartOutlined,
ImportOutlined,
LogoutOutlined,
MenuOutlined,
MessageOutlined,
MoonFilled,
MoonOutlined,
SafetyOutlined,
SettingOutlined,
SunOutlined,
SwapOutlined,
TagsOutlined,
TeamOutlined,
ToolOutlined,
UploadOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
@@ -113,7 +120,7 @@ export default function AppSidebar() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate();
const { pathname } = useLocation();
const { pathname, hash } = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -136,18 +143,51 @@ export default function AppSidebar() {
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = useMemo(() => tabs.filter((tab) => tab.icon === 'logout'), [tabs]);
const selectedKey = pathname === '' ? '/' : pathname;
const settingsChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
{ key: '/settings#general', icon: <SettingOutlined />, label: t('pages.settings.panelSettings') },
{ key: '/settings#security', icon: <SafetyOutlined />, label: t('pages.settings.securitySettings') },
{ key: '/settings#telegram', icon: <MessageOutlined />, label: t('pages.settings.TGBotSettings') },
{ key: '/settings#subscription', icon: <CloudServerOutlined />, label: t('pages.settings.subSettings') },
{ key: '/settings#subscription-formats', icon: <CodeOutlined />, label: 'Sub Formats' },
], [t]);
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
{ key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
{ key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
], [t]);
const settingsActive = pathname === '/settings';
const xrayActive = pathname === '/xray';
const selectedKey = settingsActive
? `/settings${hash || '#general'}`
: xrayActive
? `/xray${hash || '#basic'}`
: (pathname === '' ? '/' : pathname);
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
useEffect(() => {
if (openSubmenu) {
setOpenKeys((keys) => (keys.includes(openSubmenu) ? keys : [...keys, openSubmenu]));
}
}, [openSubmenu]);
const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => {
const Icon = iconByName[tab.icon];
return {
key: tab.key,
icon: <Icon />,
label: tab.title,
};
if (tab.key === '/settings') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
}
if (tab.key === '/xray') {
return { key: tab.key, icon: <Icon />, label: tab.title, children: xrayChildren };
}
return { key: tab.key, icon: <Icon />, label: tab.title };
}),
[]);
[settingsChildren, xrayChildren]);
const openLink = useCallback(async (key: string) => {
if (key === LOGOUT_KEY) {
@@ -212,6 +252,8 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav"
items={toMenuItems(navItems)}
onClick={onMenuClick}
@@ -269,6 +311,8 @@ export default function AppSidebar() {
theme={currentTheme}
mode="inline"
selectedKeys={[selectedKey]}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="drawer-menu drawer-nav"
items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}

View File

@@ -1,15 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Collapse,
Input,
InputNumber,
Select,
Switch,
Tabs,
} from 'antd';
import {
ApartmentOutlined,
BellOutlined,
ClockCircleOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath';
interface ApiMsg<T = unknown> {
@@ -29,6 +39,7 @@ const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
@@ -82,10 +93,10 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
);
return (
<Collapse defaultActiveKey="1" items={[
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}>
@@ -148,7 +159,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '2',
label: t('pages.settings.notifications'),
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}>
@@ -164,7 +175,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '3',
label: t('pages.settings.certs'),
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}>
@@ -178,7 +189,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '4',
label: t('pages.settings.externalTraffic'),
label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}>
@@ -201,7 +212,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '5',
label: t('pages.settings.dateAndTime'),
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}>
@@ -220,7 +231,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
},
{
key: '6',
label: 'LDAP',
label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}>

View File

@@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Collapse,
Empty,
Form,
Input,
@@ -10,11 +9,15 @@ import {
Space,
Spin,
Switch,
Tabs,
message,
} from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css';
@@ -59,6 +62,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
@@ -248,10 +252,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
<>
{messageContextHolder}
{modalContextHolder}
<Collapse defaultActiveKey="1" items={[
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.security.admin'),
label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
@@ -282,7 +286,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
},
{
key: '2',
label: t('pages.settings.security.twoFactor'),
label: catTabLabel(<SafetyOutlined />, t('pages.settings.security.twoFactor'), isMobile),
children: (
<SettingListItem
paddings="small"
@@ -295,7 +299,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
},
{
key: '3',
label: t('pages.nodes.apiToken'),
label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
children: (
<div className="api-token-section">
<div className="api-token-header">

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import {
Alert,
Button,
@@ -12,17 +13,8 @@ import {
Row,
Space,
Spin,
Tabs,
Tooltip,
message,
} from 'antd';
import {
CloudServerOutlined,
CodeOutlined,
MessageOutlined,
SafetyOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
@@ -44,15 +36,6 @@ interface ApiMsg {
const tabSlugs = ['general', 'security', 'telegram', 'subscription', 'subscription-formats'];
function slugToKey(slug: string): string {
const i = tabSlugs.indexOf(slug);
return i >= 0 ? String(i + 1) : '1';
}
function keyToSlug(key: string): string {
return tabSlugs[Number(key) - 1] || tabSlugs[0];
}
function isIp(h: string): boolean {
if (typeof h !== 'string') return false;
const v4 = h.split('.');
@@ -108,21 +91,9 @@ export default function SettingsPage() {
}, []);
const [alertVisible, setAlertVisible] = useState(true);
const [activeTabKey, setActiveTabKey] = useState<string>(() => slugToKey(window.location.hash.slice(1)));
useEffect(() => {
const onHashChange = () => setActiveTabKey(slugToKey(window.location.hash.slice(1)));
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, []);
function onTabChange(key: string) {
setActiveTabKey(key);
const slug = keyToSlug(key);
if (window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
const location = useLocation();
const slug = location.hash.replace(/^#/, '');
const activeSlug = tabSlugs.includes(slug) ? slug : 'general';
function rebuildUrlAfterRestart(): string {
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = allSetting;
@@ -222,58 +193,15 @@ export default function SettingsPage() {
return classes.join(' ');
}, [isDark, isUltra]);
const tabItems = useMemo(() => {
const items: { key: string; label: React.ReactNode; children: React.ReactNode }[] = [
{
key: '1',
label: (
<Tooltip title={isMobile ? t('pages.settings.panelSettings') : null}>
<span><SettingOutlined />{!isMobile && <> {t('pages.settings.panelSettings')}</>}</span>
</Tooltip>
),
children: <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '2',
label: (
<Tooltip title={isMobile ? t('pages.settings.securitySettings') : null}>
<span><SafetyOutlined />{!isMobile && <> {t('pages.settings.securitySettings')}</>}</span>
</Tooltip>
),
children: <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '3',
label: (
<Tooltip title={isMobile ? t('pages.settings.TGBotSettings') : null}>
<span><MessageOutlined />{!isMobile && <> {t('pages.settings.TGBotSettings')}</>}</span>
</Tooltip>
),
children: <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />,
},
{
key: '4',
label: (
<Tooltip title={isMobile ? t('pages.settings.subSettings') : null}>
<span><CloudServerOutlined />{!isMobile && <> {t('pages.settings.subSettings')}</>}</span>
</Tooltip>
),
children: <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />,
},
];
if (allSetting.subJsonEnable || allSetting.subClashEnable) {
items.push({
key: '5',
label: (
<Tooltip title={isMobile ? `${t('pages.settings.subSettings')} (Formats)` : null}>
<span><CodeOutlined />{!isMobile && <> {t('pages.settings.subSettings')} (Formats)</>}</span>
</Tooltip>
),
children: <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />,
});
const categoryBody = useMemo(() => {
switch (activeSlug) {
case 'security': return <SecurityTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'telegram': return <TelegramTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription': return <SubscriptionGeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-formats': return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
default: return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
return items;
}, [allSetting, updateSetting, isMobile, t]);
}, [activeSlug, allSetting, updateSetting]);
return (
<ConfigProvider theme={antdThemeConfig}>
@@ -331,12 +259,7 @@ export default function SettingsPage() {
<Col span={24}>
<Card hoverable>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={tabItems}
/>
{categoryBody}
</Card>
</Col>
</Row>

View File

@@ -1,3 +1,14 @@
.nested-block {
padding: 10px 20px;
.format-settings {
margin-bottom: 8px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: 8px;
overflow: hidden;
}
.format-settings-list {
padding-top: 4px;
}
.noise-card {
margin-bottom: 10px;
}

View File

@@ -2,15 +2,26 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Collapse,
Card,
Input,
InputNumber,
Select,
Space,
Switch,
Tabs,
} from 'antd';
import {
DeleteOutlined,
PartitionOutlined,
PlusOutlined,
ScissorOutlined,
SendOutlined,
SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
import './SubscriptionFormatsTab.css';
@@ -72,6 +83,7 @@ function readJson<T>(raw: string, fallback: T): T {
export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== '';
@@ -190,10 +202,10 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}
return (
<Collapse defaultActiveKey="1" items={[
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
{allSetting.subJsonEnable && (
@@ -239,40 +251,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
},
{
key: '2',
label: t('pages.settings.fragment'),
label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
<Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem>
{fragment && (
<div className="nested-block">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.fragmentSett'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
<Input value={fragmentObj.length} placeholder="100-200"
onChange={(e) => setFragmentField('length', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
<Input value={fragmentObj.interval} placeholder="10-20"
onChange={(e) => setFragmentField('interval', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
<Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem>
</>
),
},
]} />
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.length')}>
<Input value={fragmentObj.length} placeholder="100-200"
onChange={(e) => setFragmentField('length', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.interval')}>
<Input value={fragmentObj.interval} placeholder="10-20"
onChange={(e) => setFragmentField('interval', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.maxSplit')}>
<Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem>
</div>
)}
</>
@@ -280,54 +282,60 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
},
{
key: '3',
label: t('pages.settings.subFormats.noises'),
label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem>
{noisesEnabled && (
<div className="nested-block">
<Collapse items={noisesArray.map((noise, index) => ({
key: String(index),
label: t('pages.settings.subFormats.noiseItem', { n: index + 1 }),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
<Select
value={noise.type}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'type', v)}
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
<Input value={noise.packet} placeholder="5-10"
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
<Input value={noise.delay} placeholder="10-20"
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
<Select
value={noise.applyTo}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<Space style={{ padding: '10px 20px' }}>
{noisesArray.length > 1 && (
<Button type="primary" danger onClick={() => removeNoise(index)}>
{t('delete')}
</Button>
)}
</Space>
</>
),
}))} />
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>{t('pages.settings.subFormats.addNoise')}</Button>
<div className="format-settings-list">
{noisesArray.map((noise, index) => (
<Card
key={index}
size="small"
className="noise-card"
title={t('pages.settings.subFormats.noiseItem', { n: index + 1 })}
extra={noisesArray.length > 1 ? (
<Button
size="small"
danger
icon={<DeleteOutlined />}
aria-label={t('delete')}
onClick={() => removeNoise(index)}
/>
) : null}
styles={{ body: { padding: 0 } }}
>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
<Select
value={noise.type}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'type', v)}
options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packet')}>
<Input value={noise.packet} placeholder="5-10"
onChange={(e) => updateNoiseField(index, 'packet', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.delayMs')}>
<Input value={noise.delay} placeholder="10-20"
onChange={(e) => updateNoiseField(index, 'delay', e.target.value)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.applyTo')}>
<Select
value={noise.applyTo}
style={{ width: '100%' }}
onChange={(v) => updateNoiseField(index, 'applyTo', v)}
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</Card>
))}
<Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
{t('pages.settings.subFormats.addNoise')}
</Button>
</div>
)}
</>
@@ -335,40 +343,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
},
{
key: '4',
label: t('pages.settings.mux'),
label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}>
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<div className="nested-block">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.muxSett'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</>
),
},
]} />
<div className="format-settings">
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpConcurrency')}>
<InputNumber value={muxObj.xudpConcurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpConcurrency', Number(v) || 0)} />
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.xudpUdp443')}>
<Select
value={muxObj.xudpProxyUDP443}
style={{ width: '100%' }}
onChange={(v) => setMuxField('xudpProxyUDP443', v)}
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/>
</SettingListItem>
</div>
)}
</>
@@ -376,42 +374,32 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
},
{
key: '5',
label: t('pages.settings.direct'),
label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}>
<Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem>
{directEnabled && (
<div className="nested-block">
<Collapse items={[
{
key: 'rules',
label: t('pages.settings.direct'),
children: (
<>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</>
),
},
]} />
<div className="format-settings">
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select
mode="tags"
value={directIPs}
style={{ width: '100%' }}
onChange={setDirectIPs}
options={directIPsOptions}
/>
</SettingListItem>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} {t('domainName')}</>}>
<Select
mode="tags"
value={directDomains}
style={{ width: '100%' }}
onChange={setDirectDomains}
options={directDomainsOptions}
/>
</SettingListItem>
</div>
)}
</>

View File

@@ -1,8 +1,11 @@
import { useMemo } from 'react';
import { Collapse, Divider, Input, InputNumber, Select, Space, Switch } from 'antd';
import { Divider, Input, InputNumber, Select, Space, Switch, Tabs } from 'antd';
import { ClockCircleOutlined, InfoCircleOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
const REMARK_MODELS: Record<string, string> = { i: 'Inbound', e: 'Email', o: 'Other' };
@@ -16,6 +19,7 @@ interface SubscriptionGeneralTabProps {
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const remarkModel = useMemo(() => {
const rm = allSetting.remarkModel || '';
@@ -42,10 +46,10 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
}
return (
<Collapse defaultActiveKey="1" items={[
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEnable')} description={t('pages.settings.subEnableDesc')}>
@@ -84,7 +88,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
},
{
key: '2',
label: t('pages.settings.information'),
label: catTabLabel(<InfoCircleOutlined />, t('pages.settings.information'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subEncrypt')} description={t('pages.settings.subEncryptDesc')}>
@@ -167,7 +171,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
},
{
key: '3',
label: t('pages.settings.certs'),
label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subCertPath')} description={t('pages.settings.subCertPathDesc')}>
@@ -181,7 +185,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
},
{
key: '4',
label: t('pages.settings.intervals'),
label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.intervals'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subUpdates')} description={t('pages.settings.subUpdatesDesc')}>

View File

@@ -1,9 +1,12 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Input, InputNumber, Select, Switch } from 'antd';
import { Input, InputNumber, Select, Switch, Tabs } from 'antd';
import { BellOutlined, SettingOutlined } from '@ant-design/icons';
import { LanguageManager } from '@/utils';
import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
interface TelegramTabProps {
allSetting: AllSetting;
@@ -12,6 +15,7 @@ interface TelegramTabProps {
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
@@ -27,10 +31,10 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
);
return (
<Collapse defaultActiveKey="1" items={[
<Tabs defaultActiveKey="1" items={[
{
key: '1',
label: t('pages.settings.panelSettings'),
label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}>
@@ -71,7 +75,7 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
},
{
key: '2',
label: t('pages.settings.notifications'),
label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}>

View File

@@ -0,0 +1,17 @@
import type { ReactNode } from 'react';
import { Tooltip } from 'antd';
/* Builds a settings category tab label: icon + text on desktop, and on
mobile just the icon with the text moved into a tooltip — mirroring the
old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) {
return <Tooltip title={text}>{icon}</Tooltip>;
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{icon}
<span>{text}</span>
</span>
);
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
Alert,
Button,
@@ -16,18 +17,8 @@ import {
Row,
Space,
Spin,
Tabs,
Tooltip,
} from 'antd';
import {
SettingOutlined,
SwapOutlined,
UploadOutlined,
ClusterOutlined,
DatabaseOutlined,
CodeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -45,18 +36,7 @@ import { DnsTab } from './dns';
import { WarpModal, NordModal } from './overrides';
import './XrayPage.css';
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced'];
const SLUG_BY_KEY: Record<string, string> = {
'tpl-basic': 'basic',
'tpl-routing': 'routing',
'tpl-outbound': 'outbound',
'tpl-balancer': 'balancer',
'tpl-dns': 'dns',
'tpl-advanced': 'advanced',
};
const KEY_BY_SLUG: Record<string, string> = Object.fromEntries(
Object.entries(SLUG_BY_KEY).map(([k, v]) => [v, k]),
);
const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', 'advanced'];
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
@@ -97,27 +77,10 @@ export default function XrayPage() {
const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const [activeTabKey, setActiveTabKey] = useState(() => {
const slug = window.location.hash.slice(1);
return KEY_BY_SLUG[slug] || TAB_KEYS[0];
});
useEffect(() => {
function syncTabFromHash() {
const key = KEY_BY_SLUG[window.location.hash.slice(1)];
if (key) setActiveTabKey(key);
}
window.addEventListener('hashchange', syncTabFromHash);
return () => window.removeEventListener('hashchange', syncTabFromHash);
}, []);
function onTabChange(key: string) {
setActiveTabKey(key);
const slug = SLUG_BY_KEY[key];
if (slug && window.location.hash !== `#${slug}`) {
history.replaceState(null, '', `#${slug}`);
}
}
const location = useLocation();
const navigate = useNavigate();
const sectionSlug = location.hash.replace(/^#/, '');
const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => {
@@ -235,7 +198,7 @@ export default function XrayPage() {
JSON.parse(xraySetting);
} catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced');
navigate('/xray#advanced');
return;
}
saveAll();
@@ -245,6 +208,95 @@ export default function XrayPage() {
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim();
const sectionBody = (() => {
switch (activeSection) {
case 'routing':
return (
<RoutingTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
);
case 'outbound':
return (
<OutboundsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
/>
);
case 'balancer':
return (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
);
case 'dns':
return (
<DnsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
/>
);
case 'advanced':
return (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
);
default:
return (
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundTestUrl={outboundTestUrl}
onChangeOutboundTestUrl={setOutboundTestUrl}
warpExist={warpExist}
nordExist={nordExist}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onResetDefault={resetToDefault}
/>
);
}
})();
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
@@ -298,145 +350,7 @@ export default function XrayPage() {
<Col span={24}>
<Card hoverable>
<Tabs
activeKey={activeTabKey}
onChange={onTabChange}
className={isMobile ? 'icons-only' : ''}
items={[
{
key: 'tpl-basic',
label: (
<Tooltip title={isMobile ? t('pages.xray.basicTemplate') : ''}>
<SettingOutlined />
{!isMobile && <span>{` ${t('pages.xray.basicTemplate')}`}</span>}
</Tooltip>
),
children: (
<BasicsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundTestUrl={outboundTestUrl}
onChangeOutboundTestUrl={setOutboundTestUrl}
warpExist={warpExist}
nordExist={nordExist}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
onResetDefault={resetToDefault}
/>
),
},
{
key: 'tpl-routing',
label: (
<Tooltip title={isMobile ? t('pages.xray.Routings') : ''}>
<SwapOutlined />
{!isMobile && <span>{` ${t('pages.xray.Routings')}`}</span>}
</Tooltip>
),
children: (
<RoutingTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
inboundTags={inboundTags}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
),
},
{
key: 'tpl-outbound',
label: (
<Tooltip title={isMobile ? t('pages.xray.Outbounds') : ''}>
<UploadOutlined />
{!isMobile && <span>{` ${t('pages.xray.Outbounds')}`}</span>}
</Tooltip>
),
children: (
<OutboundsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
outboundsTraffic={outboundsTraffic}
outboundTestStates={outboundTestStates}
testingAll={testingAll}
inboundTags={inboundTags}
isMobile={isMobile}
onResetTraffic={resetOutboundsTraffic}
onTest={onTestOutbound}
onTestAll={testAllOutbounds}
onShowWarp={() => setWarpOpen(true)}
onShowNord={() => setNordOpen(true)}
/>
),
},
{
key: 'tpl-balancer',
label: (
<Tooltip title={isMobile ? t('pages.xray.Balancers') : ''}>
<ClusterOutlined />
{!isMobile && <span>{` ${t('pages.xray.Balancers')}`}</span>}
</Tooltip>
),
children: (
<BalancersTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
clientReverseTags={clientReverseTags}
isMobile={isMobile}
/>
),
},
{
key: 'tpl-dns',
label: (
<Tooltip title={isMobile ? 'DNS' : ''}>
<DatabaseOutlined />
{!isMobile && <span> DNS</span>}
</Tooltip>
),
children: (
<DnsTab
templateSettings={templateSettings}
setTemplateSettings={setTemplateSettings}
/>
),
},
{
key: 'tpl-advanced',
label: (
<Tooltip title={isMobile ? t('pages.xray.advancedTemplate') : ''}>
<CodeOutlined />
{!isMobile && <span>{` ${t('pages.xray.advancedTemplate')}`}</span>}
</Tooltip>
),
children: (
<>
<div className="advanced-meta">
<h4>{t('pages.xray.Template')}</h4>
<p>{t('pages.xray.TemplateDesc')}</p>
</div>
<Radio.Group
value={advSettings}
buttonStyle="solid"
size={isMobile ? 'small' : 'middle'}
style={{ margin: '12px 0' }}
onChange={(e) => setAdvSettings(e.target.value)}
>
<Radio.Button value="xraySetting">{t('pages.xray.completeTemplate')}</Radio.Button>
<Radio.Button value="inboundSettings">{t('pages.xray.Inbounds')}</Radio.Button>
<Radio.Button value="outboundSettings">{t('pages.xray.Outbounds')}</Radio.Button>
<Radio.Button value="routingRuleSettings">{t('pages.xray.Routings')}</Radio.Button>
</Radio.Group>
<JsonEditor
value={advancedText}
onChange={onAdvancedTextChange}
minHeight="420px"
maxHeight="720px"
/>
</>
),
},
]}
/>
{sectionBody}
</Card>
</Col>
</Row>

View File

@@ -1,10 +1,20 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd';
import { CloudOutlined, ApiOutlined } from '@ant-design/icons';
import { Alert, Button, Input, Modal, Select, Space, Switch, Tabs } from 'antd';
import {
ApiOutlined,
BarChartOutlined,
CloudOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css';
@@ -48,6 +58,7 @@ export default function BasicsTab({
onResetDefault,
}: BasicsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const mutate = useCallback(
@@ -97,7 +108,7 @@ export default function BasicsTab({
const items = [
{
key: '1',
label: t('pages.xray.generalConfigs'),
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<Alert
@@ -161,7 +172,7 @@ export default function BasicsTab({
},
{
key: '2',
label: t('pages.xray.statistics'),
label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: (
<>
{[
@@ -191,7 +202,7 @@ export default function BasicsTab({
},
{
key: '3',
label: t('pages.xray.logConfigs'),
label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: (
<>
<Alert
@@ -268,7 +279,7 @@ export default function BasicsTab({
},
{
key: '4',
label: t('pages.xray.basicRouting'),
label: catTabLabel(<SwapOutlined />, t('pages.xray.basicRouting'), isMobile),
children: (
<>
<Alert
@@ -427,10 +438,10 @@ export default function BasicsTab({
},
{
key: 'reset',
label: t('pages.settings.resetDefaultConfig'),
label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: (
<Space style={{ padding: '0 20px' }}>
<Button danger onClick={confirmResetDefault}>
<Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')}
</Button>
</Space>
@@ -441,7 +452,7 @@ export default function BasicsTab({
return (
<>
{modalContextHolder}
<Collapse defaultActiveKey={['1']} items={items} />
<Tabs defaultActiveKey="1" items={items} />
</>
);
}

View File

@@ -1,9 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd';
import { PlusOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons';
import { Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal';
@@ -21,6 +31,7 @@ interface DnsTabProps {
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false);
@@ -199,7 +210,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const out = [
{
key: '1',
label: t('pages.xray.generalConfigs'),
label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: (
<>
<SettingListItem
@@ -292,7 +303,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
if (dnsEnabled) {
out.push({
key: 'hosts',
label: t('pages.xray.dns.hosts'),
label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
children: hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
@@ -335,7 +346,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({
key: '2',
label: 'DNS',
label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
children: dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}>
<Space>
@@ -374,7 +385,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({
key: '3',
label: 'Fake DNS',
label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
children: fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
@@ -401,12 +412,12 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
}, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return (
<>
{modalContextHolder}
<Collapse defaultActiveKey={['1']} items={items} />
<Tabs defaultActiveKey="1" items={items} />
<DnsServerModal
open={serverModalOpen}
server={editingServer}