Procházet zdrojové kódy

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.
MHSanaei před 1 dnem
rodič
revize
ac89ec724f

+ 54 - 10
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<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); }}

+ 19 - 8
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<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')}>

+ 9 - 5
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}
-      <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">

+ 13 - 90
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<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>

+ 13 - 2
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;
 }

+ 120 - 132
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<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>
             )}
           </>

+ 10 - 6
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -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')}>

+ 8 - 4
frontend/src/pages/settings/TelegramTab.tsx

@@ -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')}>

+ 17 - 0
frontend/src/pages/settings/catTabLabel.tsx

@@ -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>
+  );
+}

+ 98 - 184
frontend/src/pages/xray/XrayPage.tsx

@@ -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>

+ 20 - 9
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -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} />
     </>
   );
 }

+ 19 - 8
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -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}