Files
3x-ui/frontend/src/pages/settings/SubscriptionGeneralTab.tsx
T
Hamed 136c1c4d50 feat(hosts): add drag-and-drop and global subscription ordering (#6895)
Hosts already had one saved order on the Hosts page, but subscriptions honoured
it only inside each inbound, so a host of a later inbound could never come before
one of an earlier inbound (#6894). A new opt-in setting makes raw/base64,
Clash/Mihomo and JSON subscriptions follow the Hosts page order across inbounds;
the default keeps today's inbound-first output unchanged. The Hosts table gains
drag-and-drop reordering next to the Up/Down buttons, on a pointer-reorder hook
the Routing Rules table now shares.
2026-10-24 07:27:47 +02:00

507 lines
19 KiB
TypeScript

import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
import {
BranchesOutlined,
CompassOutlined,
IdcardOutlined,
InfoCircleOutlined,
NodeIndexOutlined,
SafetyCertificateOutlined,
SettingOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router';
import type { AllSetting } from '@/models/setting';
import type { SubProfileMode, SubHostSortMode } from '@/schemas/setting';
import { onNumber } from '@/utils/onNumber';
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
import { RemarkTemplateField } from '@/components/form';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from './catTabLabel';
import { sanitizePath, normalizePath } from './uriPath';
import HappSettingsContent from './HappSettingsContent';
import IncySettingsContent from './IncySettingsContent';
import { remoteSourceBadge } from './subscriptionShared';
interface SubscriptionGeneralTabProps {
allSetting: AllSetting;
updateSetting: (patch: Partial<AllSetting>) => void;
}
const PANEL_SETTINGS_TAB = '1';
const HAPP_SETTINGS_TAB = '5';
export default function SubscriptionGeneralTab({
allSetting,
updateSetting,
}: SubscriptionGeneralTabProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { isMobile } = useMediaQuery();
// Keep the URL semantic while mapping to the legacy numeric key used by these inner tabs.
const initialTab =
searchParams.get('subscriptionTab') === 'happ' ? HAPP_SETTINGS_TAB : PANEL_SETTINGS_TAB;
return (
<Tabs
defaultActiveKey={initialTab}
items={[
{
key: '1',
label: catTabLabel(
<SettingOutlined aria-hidden />,
t('pages.settings.panelSettings'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEnable')}
description={t('pages.settings.subEnableDesc')}
>
<Switch
checked={allSetting.subEnable}
onChange={(v) => updateSetting({ subEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subJsonEnableTitle')}
description={t('pages.settings.subJsonEnable')}
>
<Switch
checked={allSetting.subJsonEnable}
onChange={(v) => updateSetting({ subJsonEnable: v })}
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.subClashEnableTitle')}>
<Switch
checked={allSetting.subClashEnable}
onChange={(v) => updateSetting({ subClashEnable: v })}
/>
</SettingListItem>
{(allSetting.subJsonEnable || allSetting.subClashEnable) && (
<Alert
type="info"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subFormatsTipTitle')}
description={t('pages.settings.subFormatsTipDesc')}
action={
<Button size="small" onClick={() => navigate('/settings#subscription-formats')}>
{t('pages.settings.subFormatsTipAction')}
</Button>
}
/>
)}
<SettingListItem
paddings="small"
title={t('pages.settings.subHostSortMode')}
description={t('pages.settings.subHostSortModeDesc')}
>
<Select
id="sub-host-sort-mode"
aria-label={t('pages.settings.subHostSortMode')}
value={allSetting.subHostSortMode}
onChange={(value: SubHostSortMode) => updateSetting({ subHostSortMode: value })}
options={[
{ value: 'inbound-first', label: t('pages.settings.subHostSortInbound') },
{ value: 'hosts-first', label: t('pages.settings.subHostSortHosts') },
]}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subListen')}
description={t('pages.settings.subListenDesc')}
>
<Input
value={allSetting.subListen}
onChange={(e) => updateSetting({ subListen: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subDomain')}
description={t('pages.settings.subDomainDesc')}
>
<Input
value={allSetting.subDomain}
onChange={(e) => updateSetting({ subDomain: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subPort')}
badge={<DefaultSettingTag settingKey="subPort" value={allSetting.subPort} />}
description={t('pages.settings.subPortDesc')}
>
<InputNumber
value={allSetting.subPort}
min={1}
max={65535}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subPort: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subPath')}
description={t('pages.settings.subPathDesc')}
>
<Input
value={allSetting.subPath}
placeholder="/sub/"
onChange={(e) => updateSetting({ subPath: sanitizePath(e.target.value) })}
onBlur={() => updateSetting({ subPath: normalizePath(allSetting.subPath) })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subURI')}
description={t('pages.settings.subURIDesc')}
>
<Input
value={allSetting.subURI}
placeholder="(http|https)://domain[:port]/path/"
onChange={(e) => updateSetting({ subURI: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '2',
label: catTabLabel(
<InfoCircleOutlined aria-hidden />,
t('pages.settings.information'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subEncrypt')}
description={t('pages.settings.subEncryptDesc')}
>
<Switch
checked={allSetting.subEncrypt}
onChange={(v) => updateSetting({ subEncrypt: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.remarkTemplate')}
description={t('pages.settings.remarkTemplateDesc')}
>
<RemarkTemplateField
value={allSetting.remarkTemplate}
onChange={(v) => updateSetting({ remarkTemplate: v })}
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subShowIdentityOnAllLinks')}
description={t('pages.settings.subShowIdentityOnAllLinksDesc')}
>
<Switch
checked={allSetting.subShowIdentityOnAllLinks}
onChange={(v) => updateSetting({ subShowIdentityOnAllLinks: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subInfoNodeEnable')}
description={t('pages.settings.subInfoNodeEnableDesc')}
>
<Switch
checked={allSetting.subInfoNodeEnable}
onChange={(v) => updateSetting({ subInfoNodeEnable: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subCalendarExpireInclusive')}
description={t('pages.settings.subCalendarExpireInclusiveDesc')}
>
<Switch
checked={allSetting.subCalendarExpireInclusive}
onChange={(v) => updateSetting({ subCalendarExpireInclusive: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subExpiredTemplate')}
description={t('pages.settings.subExpiredTemplateDesc')}
>
<RemarkTemplateField
value={allSetting.subExpiredTemplate}
onChange={(v) => updateSetting({ subExpiredTemplate: v })}
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subTrafficDepletedTemplate')}
description={t('pages.settings.subTrafficDepletedTemplateDesc')}
>
<RemarkTemplateField
value={allSetting.subTrafficDepletedTemplate}
onChange={(v) => updateSetting({ subTrafficDepletedTemplate: v })}
maxLength={256}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subUpdates')}
badge={<DefaultSettingTag settingKey="subUpdates" value={allSetting.subUpdates} />}
description={t('pages.settings.subUpdatesDesc')}
>
<InputNumber
value={allSetting.subUpdates}
min={0}
max={525600}
style={{ width: '100%' }}
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.externalSubUserAgent')}
badge={
<DefaultSettingTag
settingKey="externalSubUserAgent"
value={allSetting.externalSubUserAgent}
/>
}
description={t('pages.settings.externalSubUserAgentDesc')}
>
<Input
value={allSetting.externalSubUserAgent}
placeholder="v2rayNG/1.8.5"
maxLength={512}
allowClear
onChange={(e) => updateSetting({ externalSubUserAgent: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '3',
label: catTabLabel(<IdcardOutlined aria-hidden />, t('pages.settings.profile'), isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subTitle')}
description={t('pages.settings.subTitleDesc')}
>
<RemarkTemplateField
value={allSetting.subTitle}
onChange={(v) => updateSetting({ subTitle: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subSupportUrl')}
description={t('pages.settings.subSupportUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subSupportUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subSupportUrl: v })}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subProfileMode')}
description={t('pages.settings.subProfileModeDesc')}
>
<Select<SubProfileMode>
id="sub-profile-mode"
aria-label={t('pages.settings.subProfileMode')}
value={allSetting.subProfileMode}
style={{ width: '100%' }}
onChange={(value) => updateSetting({ subProfileMode: value })}
options={[
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
]}
/>
</SettingListItem>
{allSetting.subProfileMode === 'builtin' ? (
<Alert
type="warning"
showIcon
style={{ margin: '12px 20px' }}
title={t('pages.settings.subProfileBuiltinWarning')}
/>
) : null}
{allSetting.subProfileMode === 'custom' ? (
<SettingListItem
paddings="small"
title={t('pages.settings.subProfileUrl')}
description={t('pages.settings.subProfileUrlDesc')}
>
<RemarkTemplateField
value={allSetting.subProfileUrl}
placeholder="https://example.com"
onChange={(v) => updateSetting({ subProfileUrl: v })}
metadataOnly
/>
</SettingListItem>
) : null}
<SettingListItem
paddings="small"
title={t('pages.settings.subAnnounce')}
description={t('pages.settings.subAnnounceDesc')}
>
<RemarkTemplateField
value={allSetting.subAnnounce}
onChange={(v) => updateSetting({ subAnnounce: v })}
multiline
rows={3}
metadataOnly
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subThemeDir')}
description={
<>
{t('pages.settings.subThemeDirDesc')}{' '}
<a
href="https://github.com/MHSanaei/3x-ui/blob/main/docs/custom-subscription-templates.md"
target="_blank"
rel="noopener noreferrer"
>
{t('pages.settings.subThemeDirDocs')}
</a>
</>
}
>
<Input
value={allSetting.subThemeDir}
placeholder="/etc/3x-ui/sub_templates/my-theme/"
onChange={(e) => updateSetting({ subThemeDir: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '4',
label: catTabLabel(
<SafetyCertificateOutlined aria-hidden />,
t('pages.settings.certs'),
isMobile,
),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subCertPath')}
description={t('pages.settings.subCertPathDesc')}
>
<Input
value={allSetting.subCertFile}
onChange={(e) => updateSetting({ subCertFile: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subKeyPath')}
description={t('pages.settings.subKeyPathDesc')}
>
<Input
value={allSetting.subKeyFile}
onChange={(e) => updateSetting({ subKeyFile: e.target.value })}
/>
</SettingListItem>
</>
),
},
{
key: '5',
label: catTabLabel(<BranchesOutlined aria-hidden />, 'Happ', isMobile),
children: (
<HappSettingsContent
allSetting={allSetting}
updateSetting={updateSetting}
isMobile={isMobile}
remoteSourceBadge={remoteSourceBadge}
/>
),
},
{
key: '6',
label: catTabLabel(<NodeIndexOutlined aria-hidden />, 'Clash / Mihomo', isMobile),
children: (
<>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashEnableRouting')}
description={t('pages.settings.subClashEnableRoutingDesc')}
>
<Switch
checked={allSetting.subClashEnableRouting}
onChange={(v) => updateSetting({ subClashEnableRouting: v })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashRoutingRules')}
badge={remoteSourceBadge(allSetting.subClashRules)}
description={t('pages.settings.subClashRoutingRulesDesc')}
>
<Input.TextArea
value={allSetting.subClashRules}
rows={8}
placeholder={
'https://.../routing.yaml\n\nor inline rules:\nGEOSITE,category-ir,DIRECT'
}
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashRemoteDNS')}
description={t('pages.settings.subClashRemoteDNSDesc')}
>
<Switch
checked={allSetting.subClashRemoteDNS}
onChange={(v) => updateSetting({ subClashRemoteDNS: v })}
/>
</SettingListItem>
</>
),
},
{
key: '7',
label: catTabLabel(<CompassOutlined aria-hidden />, 'Incy', isMobile),
children: (
<IncySettingsContent
allSetting={allSetting}
updateSetting={updateSetting}
isMobile={isMobile}
remoteSourceBadge={remoteSourceBadge}
/>
),
},
]}
/>
);
}