mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 07:40:59 +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,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')}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user