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
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
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 type { ComponentType } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -6,21 +6,28 @@ import { Drawer, Layout, Menu } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
ApiOutlined, ApiOutlined,
ClusterOutlined,
CloseOutlined, CloseOutlined,
CloudServerOutlined,
ClusterOutlined,
CodeOutlined,
DashboardOutlined, DashboardOutlined,
DatabaseOutlined,
GithubOutlined, GithubOutlined,
HeartOutlined, HeartOutlined,
ImportOutlined, ImportOutlined,
LogoutOutlined, LogoutOutlined,
MenuOutlined, MenuOutlined,
MessageOutlined,
MoonFilled, MoonFilled,
MoonOutlined, MoonOutlined,
SafetyOutlined,
SettingOutlined, SettingOutlined,
SunOutlined, SunOutlined,
SwapOutlined,
TagsOutlined, TagsOutlined,
TeamOutlined, TeamOutlined,
ToolOutlined, ToolOutlined,
UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
@ -113,7 +120,7 @@ export default function AppSidebar() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname, hash } = useLocation();
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed()); const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
@ -136,18 +143,51 @@ export default function AppSidebar() {
const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]); const navItems = useMemo(() => tabs.filter((tab) => tab.icon !== 'logout'), [tabs]);
const utilItems = 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'] => const toMenuItems = useCallback((items: typeof tabs): MenuProps['items'] =>
items.map((tab) => { items.map((tab) => {
const Icon = iconByName[tab.icon]; const Icon = iconByName[tab.icon];
return { if (tab.key === '/settings') {
key: tab.key, return { key: tab.key, icon: <Icon />, label: tab.title, children: settingsChildren };
icon: <Icon />, }
label: tab.title, 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) => { const openLink = useCallback(async (key: string) => {
if (key === LOGOUT_KEY) { if (key === LOGOUT_KEY) {
@ -212,6 +252,8 @@ export default function AppSidebar() {
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
openKeys={collapsed ? undefined : openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="sider-nav" className="sider-nav"
items={toMenuItems(navItems)} items={toMenuItems(navItems)}
onClick={onMenuClick} onClick={onMenuClick}
@ -269,6 +311,8 @@ export default function AppSidebar() {
theme={currentTheme} theme={currentTheme}
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
openKeys={openKeys}
onOpenChange={(keys) => setOpenKeys(keys as string[])}
className="drawer-menu drawer-nav" className="drawer-menu drawer-nav"
items={toMenuItems(navItems)} items={toMenuItems(navItems)}
onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }} onClick={(info) => { onMenuClick(info); setDrawerOpen(false); }}

View file

@ -1,15 +1,25 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Collapse,
Input, Input,
InputNumber, InputNumber,
Select, Select,
Switch, Switch,
Tabs,
} from 'antd'; } from 'antd';
import {
ApartmentOutlined,
BellOutlined,
ClockCircleOutlined,
GlobalOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { HttpUtil, LanguageManager } from '@/utils'; import { HttpUtil, LanguageManager } from '@/utils';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath } from './uriPath'; import { sanitizePath } from './uriPath';
interface ApiMsg<T = unknown> { interface ApiMsg<T = unknown> {
@ -29,6 +39,7 @@ const DATEPICKER_LIST: { name: string; value: 'gregorian' | 'jalalian' }[] = [
export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) { export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]); const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
@ -82,10 +93,10 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
); );
return ( return (
<Collapse defaultActiveKey="1" items={[ <Tabs defaultActiveKey="1" items={[
{ {
key: '1', key: '1',
label: t('pages.settings.panelSettings'), label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.panelListeningIP')} description={t('pages.settings.panelListeningIPDesc')}> <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', key: '2',
label: t('pages.settings.notifications'), label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.expireTimeDiff')} description={t('pages.settings.expireTimeDiffDesc')}> <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', key: '3',
label: t('pages.settings.certs'), label: catTabLabel(<SafetyCertificateOutlined />, t('pages.settings.certs'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.publicKeyPath')} description={t('pages.settings.publicKeyPathDesc')}> <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', key: '4',
label: t('pages.settings.externalTraffic'), label: catTabLabel(<GlobalOutlined />, t('pages.settings.externalTraffic'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.externalTrafficInformEnable')} description={t('pages.settings.externalTrafficInformEnableDesc')}> <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', key: '5',
label: t('pages.settings.dateAndTime'), label: catTabLabel(<ClockCircleOutlined />, t('pages.settings.dateAndTime'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.timeZone')} description={t('pages.settings.timeZoneDesc')}> <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', key: '6',
label: 'LDAP', label: catTabLabel(<ApartmentOutlined />, 'LDAP', isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.ldap.enable')}> <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 { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Collapse,
Empty, Empty,
Form, Form,
Input, Input,
@ -10,11 +9,15 @@ import {
Space, Space,
Spin, Spin,
Switch, Switch,
Tabs,
message, message,
} from 'antd'; } from 'antd';
import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils'; import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import TwoFactorModal from './TwoFactorModal'; import TwoFactorModal from './TwoFactorModal';
import './SecurityTab.css'; import './SecurityTab.css';
@ -59,6 +62,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) { export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage(); const [messageApi, messageContextHolder] = message.useMessage();
@ -248,10 +252,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
<> <>
{messageContextHolder} {messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Collapse defaultActiveKey="1" items={[ <Tabs defaultActiveKey="1" items={[
{ {
key: '1', key: '1',
label: t('pages.settings.security.admin'), label: catTabLabel(<UserOutlined />, t('pages.settings.security.admin'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.oldUsername')}> <SettingListItem paddings="small" title={t('pages.settings.oldUsername')}>
@ -282,7 +286,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
}, },
{ {
key: '2', key: '2',
label: t('pages.settings.security.twoFactor'), label: catTabLabel(<SafetyOutlined />, t('pages.settings.security.twoFactor'), isMobile),
children: ( children: (
<SettingListItem <SettingListItem
paddings="small" paddings="small"
@ -295,7 +299,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
}, },
{ {
key: '3', key: '3',
label: t('pages.nodes.apiToken'), label: catTabLabel(<ApiOutlined />, t('pages.nodes.apiToken'), isMobile),
children: ( children: (
<div className="api-token-section"> <div className="api-token-section">
<div className="api-token-header"> <div className="api-token-header">

View file

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

View file

@ -1,3 +1,14 @@
.nested-block { .format-settings {
padding: 10px 20px; 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 { useTranslation } from 'react-i18next';
import { import {
Button, Button,
Collapse, Card,
Input, Input,
InputNumber, InputNumber,
Select, Select,
Space,
Switch, Switch,
Tabs,
} from 'antd'; } from 'antd';
import {
DeleteOutlined,
PartitionOutlined,
PlusOutlined,
ScissorOutlined,
SendOutlined,
SettingOutlined,
ThunderboltOutlined,
} from '@ant-design/icons';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath'; import { sanitizePath, normalizePath } from './uriPath';
import './SubscriptionFormatsTab.css'; import './SubscriptionFormatsTab.css';
@ -72,6 +83,7 @@ function readJson<T>(raw: string, fallback: T): T {
export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) { export default function SubscriptionFormatsTab({ allSetting, updateSetting }: SubscriptionFormatsTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const fragment = allSetting.subJsonFragment !== ''; const fragment = allSetting.subJsonFragment !== '';
const noisesEnabled = allSetting.subJsonNoises !== ''; const noisesEnabled = allSetting.subJsonNoises !== '';
@ -190,10 +202,10 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
} }
return ( return (
<Collapse defaultActiveKey="1" items={[ <Tabs defaultActiveKey="1" items={[
{ {
key: '1', key: '1',
label: t('pages.settings.panelSettings'), label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: ( children: (
<> <>
{allSetting.subJsonEnable && ( {allSetting.subJsonEnable && (
@ -239,20 +251,14 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '2', key: '2',
label: t('pages.settings.fragment'), label: catTabLabel(<ScissorOutlined />, t('pages.settings.fragment'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}> <SettingListItem paddings="small" title={t('pages.settings.fragment')} description={t('pages.settings.fragmentDesc')}>
<Switch checked={fragment} onChange={setFragmentEnabled} /> <Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem> </SettingListItem>
{fragment && ( {fragment && (
<div className="nested-block"> <div className="format-settings">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.fragmentSett'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.packets')}>
<Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …" <Input value={fragmentObj.packets} placeholder="1-1 | 1-3 | tlshello | …"
onChange={(e) => setFragmentField('packets', e.target.value)} /> onChange={(e) => setFragmentField('packets', e.target.value)} />
@ -269,10 +275,6 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Input value={fragmentObj.maxSplit} placeholder="300-400" <Input value={fragmentObj.maxSplit} placeholder="300-400"
onChange={(e) => setFragmentField('maxSplit', e.target.value)} /> onChange={(e) => setFragmentField('maxSplit', e.target.value)} />
</SettingListItem> </SettingListItem>
</>
),
},
]} />
</div> </div>
)} )}
</> </>
@ -280,19 +282,31 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '3', key: '3',
label: t('pages.settings.subFormats.noises'), label: catTabLabel(<ThunderboltOutlined />, t('pages.settings.subFormats.noises'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.noises')} description={t('pages.settings.noisesDesc')}>
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} /> <Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem> </SettingListItem>
{noisesEnabled && ( {noisesEnabled && (
<div className="nested-block"> <div className="format-settings-list">
<Collapse items={noisesArray.map((noise, index) => ({ {noisesArray.map((noise, index) => (
key: String(index), <Card
label: t('pages.settings.subFormats.noiseItem', { n: index + 1 }), key={index}
children: ( 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')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.type')}>
<Select <Select
value={noise.type} value={noise.type}
@ -317,17 +331,11 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))} options={['ip', 'ipv4', 'ipv6'].map((p) => ({ value: p, label: p }))}
/> />
</SettingListItem> </SettingListItem>
<Space style={{ padding: '10px 20px' }}> </Card>
{noisesArray.length > 1 && ( ))}
<Button type="primary" danger onClick={() => removeNoise(index)}> <Button type="dashed" block icon={<PlusOutlined />} onClick={addNoise}>
{t('delete')} {t('pages.settings.subFormats.addNoise')}
</Button> </Button>
)}
</Space>
</>
),
}))} />
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>{t('pages.settings.subFormats.addNoise')}</Button>
</div> </div>
)} )}
</> </>
@ -335,20 +343,14 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '4', key: '4',
label: t('pages.settings.mux'), label: catTabLabel(<PartitionOutlined />, t('pages.settings.mux'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}> <SettingListItem paddings="small" title={t('pages.settings.mux')} description={t('pages.settings.muxDesc')}>
<Switch checked={muxEnabled} onChange={setMuxEnabled} /> <Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem> </SettingListItem>
{muxEnabled && ( {muxEnabled && (
<div className="nested-block"> <div className="format-settings">
<Collapse items={[
{
key: 'sett',
label: t('pages.settings.muxSett'),
children: (
<>
<SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}> <SettingListItem paddings="small" title={t('pages.settings.subFormats.concurrency')}>
<InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }} <InputNumber value={muxObj.concurrency} min={-1} max={1024} style={{ width: '100%' }}
onChange={(v) => setMuxField('concurrency', Number(v) || 0)} /> onChange={(v) => setMuxField('concurrency', Number(v) || 0)} />
@ -365,10 +367,6 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))} options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))}
/> />
</SettingListItem> </SettingListItem>
</>
),
},
]} />
</div> </div>
)} )}
</> </>
@ -376,20 +374,14 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
}, },
{ {
key: '5', key: '5',
label: t('pages.settings.direct'), label: catTabLabel(<SendOutlined />, t('pages.settings.direct'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}> <SettingListItem paddings="small" title={t('pages.settings.direct')} description={t('pages.settings.directDesc')}>
<Switch checked={directEnabled} onChange={setDirectEnabled} /> <Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem> </SettingListItem>
{directEnabled && ( {directEnabled && (
<div className="nested-block"> <div className="format-settings">
<Collapse items={[
{
key: 'rules',
label: t('pages.settings.direct'),
children: (
<>
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}> <SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
<Select <Select
mode="tags" mode="tags"
@ -408,10 +400,6 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
options={directDomainsOptions} options={directDomainsOptions}
/> />
</SettingListItem> </SettingListItem>
</>
),
},
]} />
</div> </div>
)} )}
</> </>

View file

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

View file

@ -1,9 +1,12 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; 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 { LanguageManager } from '@/utils';
import type { AllSetting } from '@/models/setting'; import type { AllSetting } from '@/models/setting';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
interface TelegramTabProps { interface TelegramTabProps {
allSetting: AllSetting; allSetting: AllSetting;
@ -12,6 +15,7 @@ interface TelegramTabProps {
export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) { export default function TelegramTab({ allSetting, updateSetting }: TelegramTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const langOptions = useMemo( const langOptions = useMemo(
() => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({ () => LanguageManager.supportedLanguages.map((l: { value: string; name: string; icon: string }) => ({
@ -27,10 +31,10 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
); );
return ( return (
<Collapse defaultActiveKey="1" items={[ <Tabs defaultActiveKey="1" items={[
{ {
key: '1', key: '1',
label: t('pages.settings.panelSettings'), label: catTabLabel(<SettingOutlined />, t('pages.settings.panelSettings'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.telegramBotEnable')} description={t('pages.settings.telegramBotEnableDesc')}> <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', key: '2',
label: t('pages.settings.notifications'), label: catTabLabel(<BellOutlined />, t('pages.settings.notifications'), isMobile),
children: ( children: (
<> <>
<SettingListItem paddings="small" title={t('pages.settings.telegramNotifyTime')} description={t('pages.settings.telegramNotifyTimeDesc')}> <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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@ -16,18 +17,8 @@ import {
Row, Row,
Space, Space,
Spin, Spin,
Tabs,
Tooltip,
} from 'antd'; } from 'antd';
import { import { QuestionCircleOutlined } from '@ant-design/icons';
SettingOutlined,
SwapOutlined,
UploadOutlined,
ClusterOutlined,
DatabaseOutlined,
CodeOutlined,
QuestionCircleOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
@ -45,18 +36,7 @@ import { DnsTab } from './dns';
import { WarpModal, NordModal } from './overrides'; import { WarpModal, NordModal } from './overrides';
import './XrayPage.css'; import './XrayPage.css';
const TAB_KEYS = ['tpl-basic', 'tpl-routing', 'tpl-outbound', 'tpl-balancer', 'tpl-dns', 'tpl-advanced']; const SECTION_SLUGS = ['basic', 'routing', 'outbound', 'balancer', 'dns', '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]),
);
type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings'; type AdvKey = 'xraySetting' | 'inboundSettings' | 'outboundSettings' | 'routingRuleSettings';
@ -97,27 +77,10 @@ export default function XrayPage() {
const [warpOpen, setWarpOpen] = useState(false); const [warpOpen, setWarpOpen] = useState(false);
const [nordOpen, setNordOpen] = useState(false); const [nordOpen, setNordOpen] = useState(false);
const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting'); const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
const [activeTabKey, setActiveTabKey] = useState(() => { const location = useLocation();
const slug = window.location.hash.slice(1); const navigate = useNavigate();
return KEY_BY_SLUG[slug] || TAB_KEYS[0]; const sectionSlug = location.hash.replace(/^#/, '');
}); const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
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 mutate = useCallback( const mutate = useCallback(
(mutator: (next: XraySettingsValue) => void) => { (mutator: (next: XraySettingsValue) => void) => {
@ -235,7 +198,7 @@ export default function XrayPage() {
JSON.parse(xraySetting); JSON.parse(xraySetting);
} catch (e) { } catch (e) {
messageApi.error(`Advanced JSON: ${(e as Error).message}`); messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced'); navigate('/xray#advanced');
return; return;
} }
saveAll(); saveAll();
@ -245,6 +208,95 @@ export default function XrayPage() {
const pageClass = `xray-page ${isDark ? 'is-dark' : ''} ${isUltra ? 'is-ultra' : ''}`.trim(); 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 ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder} {messageContextHolder}
@ -298,145 +350,7 @@ export default function XrayPage() {
<Col span={24}> <Col span={24}>
<Card hoverable> <Card hoverable>
<Tabs {sectionBody}
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"
/>
</>
),
},
]}
/>
</Card> </Card>
</Col> </Col>
</Row> </Row>

View file

@ -1,10 +1,20 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, Input, Modal, Select, Space, Switch } from 'antd'; import { Alert, Button, Input, Modal, Select, Space, Switch, Tabs } from 'antd';
import { CloudOutlined, ApiOutlined } from '@ant-design/icons'; import {
ApiOutlined,
BarChartOutlined,
CloudOutlined,
FileTextOutlined,
ReloadOutlined,
SettingOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives'; import { OutboundDomainStrategies } from '@/schemas/primitives';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
import './BasicsTab.css'; import './BasicsTab.css';
@ -48,6 +58,7 @@ export default function BasicsTab({
onResetDefault, onResetDefault,
}: BasicsTabProps) { }: BasicsTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const mutate = useCallback( const mutate = useCallback(
@ -97,7 +108,7 @@ export default function BasicsTab({
const items = [ const items = [
{ {
key: '1', key: '1',
label: t('pages.xray.generalConfigs'), label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: ( children: (
<> <>
<Alert <Alert
@ -161,7 +172,7 @@ export default function BasicsTab({
}, },
{ {
key: '2', key: '2',
label: t('pages.xray.statistics'), label: catTabLabel(<BarChartOutlined />, t('pages.xray.statistics'), isMobile),
children: ( children: (
<> <>
{[ {[
@ -191,7 +202,7 @@ export default function BasicsTab({
}, },
{ {
key: '3', key: '3',
label: t('pages.xray.logConfigs'), label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),
children: ( children: (
<> <>
<Alert <Alert
@ -268,7 +279,7 @@ export default function BasicsTab({
}, },
{ {
key: '4', key: '4',
label: t('pages.xray.basicRouting'), label: catTabLabel(<SwapOutlined />, t('pages.xray.basicRouting'), isMobile),
children: ( children: (
<> <>
<Alert <Alert
@ -427,10 +438,10 @@ export default function BasicsTab({
}, },
{ {
key: 'reset', key: 'reset',
label: t('pages.settings.resetDefaultConfig'), label: catTabLabel(<ReloadOutlined />, t('pages.settings.resetDefaultConfig'), isMobile),
children: ( children: (
<Space style={{ padding: '0 20px' }}> <Space style={{ padding: '0 20px' }}>
<Button danger onClick={confirmResetDefault}> <Button type="primary" danger icon={<ReloadOutlined />} onClick={confirmResetDefault}>
{t('pages.settings.resetDefaultConfig')} {t('pages.settings.resetDefaultConfig')}
</Button> </Button>
</Space> </Space>
@ -441,7 +452,7 @@ export default function BasicsTab({
return ( return (
<> <>
{modalContextHolder} {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 { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Collapse, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table } from 'antd'; import { Button, Empty, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs } from 'antd';
import { PlusOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons'; import {
DatabaseOutlined,
DeleteOutlined,
ExperimentOutlined,
MenuOutlined,
PlusOutlined,
ProfileOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { SettingListItem } from '@/components/ui'; import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import DnsServerModal from './DnsServerModal'; import DnsServerModal from './DnsServerModal';
import type { DnsServerValue } from './DnsServerModal'; import type { DnsServerValue } from './DnsServerModal';
import DnsPresetsModal from './DnsPresetsModal'; import DnsPresetsModal from './DnsPresetsModal';
@ -21,6 +31,7 @@ interface DnsTabProps {
export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) { export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [hostsList, setHostsList] = useState<HostRow[]>([]); const [hostsList, setHostsList] = useState<HostRow[]>([]);
const [serverModalOpen, setServerModalOpen] = useState(false); const [serverModalOpen, setServerModalOpen] = useState(false);
@ -199,7 +210,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
const out = [ const out = [
{ {
key: '1', key: '1',
label: t('pages.xray.generalConfigs'), label: catTabLabel(<SettingOutlined />, t('pages.xray.generalConfigs'), isMobile),
children: ( children: (
<> <>
<SettingListItem <SettingListItem
@ -292,7 +303,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
if (dnsEnabled) { if (dnsEnabled) {
out.push({ out.push({
key: 'hosts', key: 'hosts',
label: t('pages.xray.dns.hosts'), label: catTabLabel(<ProfileOutlined />, t('pages.xray.dns.hosts'), isMobile),
children: hostsList.length === 0 ? ( children: hostsList.length === 0 ? (
<Empty description={t('pages.xray.dns.hostsEmpty')}> <Empty description={t('pages.xray.dns.hostsEmpty')}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}> <Button type="primary" icon={<PlusOutlined />} onClick={() => syncHosts([...hostsList, { domain: '', values: [] }])}>
@ -335,7 +346,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({ out.push({
key: '2', key: '2',
label: 'DNS', label: catTabLabel(<DatabaseOutlined />, 'DNS', isMobile),
children: dnsServers.length === 0 ? ( children: dnsServers.length === 0 ? (
<Empty description={t('emptyDnsDesc')}> <Empty description={t('emptyDnsDesc')}>
<Space> <Space>
@ -374,7 +385,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
out.push({ out.push({
key: '3', key: '3',
label: 'Fake DNS', label: catTabLabel(<ExperimentOutlined />, 'Fake DNS', isMobile),
children: fakeDnsList.length === 0 ? ( children: fakeDnsList.length === 0 ? (
<Empty description={t('emptyFakeDnsDesc')}> <Empty description={t('emptyFakeDnsDesc')}>
<Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}> <Button type="primary" icon={<PlusOutlined />} onClick={addFakedns}>
@ -401,12 +412,12 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
return out; return out;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]); }, [t, isMobile, dnsEnabled, dns, hostsList, dnsServers, fakeDnsList]);
return ( return (
<> <>
{modalContextHolder} {modalContextHolder}
<Collapse defaultActiveKey={['1']} items={items} /> <Tabs defaultActiveKey="1" items={items} />
<DnsServerModal <DnsServerModal
open={serverModalOpen} open={serverModalOpen}
server={editingServer} server={editingServer}