From ac89ec724f041bfb46d23cde06d181a81f325537 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Wed, 3 Jun 2026 09:26:25 +0200 Subject: [PATCH] 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. --- frontend/src/layouts/AppSidebar.tsx | 64 +++- frontend/src/pages/settings/GeneralTab.tsx | 27 +- frontend/src/pages/settings/SecurityTab.tsx | 14 +- frontend/src/pages/settings/SettingsPage.tsx | 103 +------ .../pages/settings/SubscriptionFormatsTab.css | 15 +- .../pages/settings/SubscriptionFormatsTab.tsx | 252 ++++++++-------- .../pages/settings/SubscriptionGeneralTab.tsx | 16 +- frontend/src/pages/settings/TelegramTab.tsx | 12 +- frontend/src/pages/settings/catTabLabel.tsx | 17 ++ frontend/src/pages/xray/XrayPage.tsx | 282 ++++++------------ frontend/src/pages/xray/basics/BasicsTab.tsx | 29 +- frontend/src/pages/xray/dns/DnsTab.tsx | 27 +- 12 files changed, 400 insertions(+), 458 deletions(-) create mode 100644 frontend/src/pages/settings/catTabLabel.tsx diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 85f93f0f..f8d48f25 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -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(() => 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>(() => [ + { key: '/settings#general', icon: , label: t('pages.settings.panelSettings') }, + { key: '/settings#security', icon: , label: t('pages.settings.securitySettings') }, + { key: '/settings#telegram', icon: , label: t('pages.settings.TGBotSettings') }, + { key: '/settings#subscription', icon: , label: t('pages.settings.subSettings') }, + { key: '/settings#subscription-formats', icon: , label: 'Sub Formats' }, + ], [t]); + + const xrayChildren = useMemo>(() => [ + { key: '/xray#basic', icon: , label: t('pages.xray.basicTemplate') }, + { key: '/xray#routing', icon: , label: t('pages.xray.Routings') }, + { key: '/xray#outbound', icon: , label: t('pages.xray.Outbounds') }, + { key: '/xray#balancer', icon: , label: t('pages.xray.Balancers') }, + { key: '/xray#dns', icon: , label: 'DNS' }, + { key: '/xray#advanced', icon: , 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(() => (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: , - label: tab.title, - }; + if (tab.key === '/settings') { + return { key: tab.key, icon: , label: tab.title, children: settingsChildren }; + } + if (tab.key === '/xray') { + return { key: tab.key, icon: , label: tab.title, children: xrayChildren }; + } + return { key: tab.key, 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); }} diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index 9ff3a47b..16ca8ca1 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -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 { @@ -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(() => LanguageManager.getLanguage()); const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]); @@ -82,10 +93,10 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp ); return ( - , t('pages.settings.panelSettings'), isMobile), children: ( <> @@ -148,7 +159,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp }, { key: '2', - label: t('pages.settings.notifications'), + label: catTabLabel(, t('pages.settings.notifications'), isMobile), children: ( <> @@ -164,7 +175,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp }, { key: '3', - label: t('pages.settings.certs'), + label: catTabLabel(, t('pages.settings.certs'), isMobile), children: ( <> @@ -178,7 +189,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp }, { key: '4', - label: t('pages.settings.externalTraffic'), + label: catTabLabel(, t('pages.settings.externalTraffic'), isMobile), children: ( <> @@ -201,7 +212,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp }, { key: '5', - label: t('pages.settings.dateAndTime'), + label: catTabLabel(, t('pages.settings.dateAndTime'), isMobile), children: ( <> @@ -220,7 +231,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp }, { key: '6', - label: 'LDAP', + label: catTabLabel(, 'LDAP', isMobile), children: ( <> diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index c83dc720..8c4dd2ab 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -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} - , t('pages.settings.security.admin'), isMobile), children: ( <> @@ -282,7 +286,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr }, { key: '2', - label: t('pages.settings.security.twoFactor'), + label: catTabLabel(, t('pages.settings.security.twoFactor'), isMobile), children: ( , t('pages.nodes.apiToken'), isMobile), children: (
diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx index 13896410..7e0b8403 100644 --- a/frontend/src/pages/settings/SettingsPage.tsx +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -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(() => 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: ( - - {!isMobile && <> {t('pages.settings.panelSettings')}} - - ), - children: , - }, - { - key: '2', - label: ( - - {!isMobile && <> {t('pages.settings.securitySettings')}} - - ), - children: , - }, - { - key: '3', - label: ( - - {!isMobile && <> {t('pages.settings.TGBotSettings')}} - - ), - children: , - }, - { - key: '4', - label: ( - - {!isMobile && <> {t('pages.settings.subSettings')}} - - ), - children: , - }, - ]; - if (allSetting.subJsonEnable || allSetting.subClashEnable) { - items.push({ - key: '5', - label: ( - - {!isMobile && <> {t('pages.settings.subSettings')} (Formats)} - - ), - children: , - }); + const categoryBody = useMemo(() => { + switch (activeSlug) { + case 'security': return ; + case 'telegram': return ; + case 'subscription': return ; + case 'subscription-formats': return ; + default: return ; } - return items; - }, [allSetting, updateSetting, isMobile, t]); + }, [activeSlug, allSetting, updateSetting]); return ( @@ -331,12 +259,7 @@ export default function SettingsPage() { - + {categoryBody} diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.css b/frontend/src/pages/settings/SubscriptionFormatsTab.css index 730aeb68..8b05fa72 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.css +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.css @@ -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; } diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx index 89628c20..7cfe45f1 100644 --- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx +++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx @@ -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(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 ( - , 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(, t('pages.settings.fragment'), isMobile), children: ( <> {fragment && ( -
- - - setFragmentField('packets', e.target.value)} /> - - - setFragmentField('length', e.target.value)} /> - - - setFragmentField('interval', e.target.value)} /> - - - setFragmentField('maxSplit', e.target.value)} /> - - - ), - }, - ]} /> +
+ + setFragmentField('packets', e.target.value)} /> + + + setFragmentField('length', e.target.value)} /> + + + setFragmentField('interval', e.target.value)} /> + + + setFragmentField('maxSplit', e.target.value)} /> +
)} @@ -280,54 +282,60 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su }, { key: '3', - label: t('pages.settings.subFormats.noises'), + label: catTabLabel(, t('pages.settings.subFormats.noises'), isMobile), children: ( <> {noisesEnabled && ( -
- ({ - key: String(index), - label: t('pages.settings.subFormats.noiseItem', { n: index + 1 }), - children: ( - <> - - updateNoiseField(index, 'packet', e.target.value)} /> - - - updateNoiseField(index, 'delay', e.target.value)} /> - - - updateNoiseField(index, 'type', v)} + options={['rand', 'base64', 'str', 'hex'].map((p) => ({ value: p, label: p }))} + /> + + + updateNoiseField(index, 'packet', e.target.value)} /> + + + updateNoiseField(index, 'delay', e.target.value)} /> + + + setMuxField('xudpProxyUDP443', v)} - options={['reject', 'allow', 'skip'].map((p) => ({ value: p, label: p }))} - /> - - - ), - }, - ]} /> +
+ + setMuxField('concurrency', Number(v) || 0)} /> + + + setMuxField('xudpConcurrency', Number(v) || 0)} /> + + + - - {t('pages.settings.direct')} {t('domainName')}}> - + + {t('pages.settings.direct')} {t('domainName')}}> +