mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-30 06:57:14 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user