mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
1bdb517b88
Ant Design's Spin wraps its content in aria-live="polite", so every WebSocket tick on the dashboard, inbounds, clients and nodes pages was announced, and only the changed number was read, with no label. Pass aria-live="off" on every Spin that wraps content; aria-busy still signals loading. Each vital tile is now a group named by its metric, so "47.2 %" is read as CPU. rc-select renders each tag's remove "x" as an aria-hidden span with no tabIndex, so screen-reader users could only Backspace the last value away, never remove one SNI or inbound from the middle. selectTagRender swaps in a real button named "Remove <value>" and moves focus to the next tag after removal. It is applied to every tags/multiple Select. maxTagCount="responsive" is dropped because it hid selected inbounds behind "+N", where no screen reader could reach them. Two oxlint rules (antd-a11y/spin-content-not-live and antd-a11y/select-tag-remove) keep new code from regressing either fix.
498 lines
19 KiB
TypeScript
498 lines
19 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
|
import {
|
|
FileTextOutlined,
|
|
NodeIndexOutlined,
|
|
PartitionOutlined,
|
|
RocketOutlined,
|
|
SendOutlined,
|
|
SettingOutlined,
|
|
StopOutlined,
|
|
} from '@ant-design/icons';
|
|
import type { AllSetting } from '@/models/setting';
|
|
import { onNumber } from '@/utils/onNumber';
|
|
import { SettingListItem } from '@/components/ui';
|
|
import { GoRegexInput, selectTagRender } from '@/components/form';
|
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
|
import { catTabLabel } from './catTabLabel';
|
|
import { sanitizePath, normalizePath } from './uriPath';
|
|
import { remoteSourceBadge } from './subscriptionShared';
|
|
import SubJsonFinalMaskForm from './SubJsonFinalMaskForm';
|
|
import './SubscriptionFormatsTab.css';
|
|
|
|
interface SubscriptionFormatsTabProps {
|
|
allSetting: AllSetting;
|
|
updateSetting: (patch: Partial<AllSetting>) => void;
|
|
}
|
|
|
|
const DEFAULT_MUX = {
|
|
enabled: true,
|
|
concurrency: 8,
|
|
xudpConcurrency: 16,
|
|
xudpProxyUDP443: 'reject',
|
|
};
|
|
|
|
type SubJsonRule = { type: string; outboundTag: string; domain?: string[]; ip?: string[] };
|
|
|
|
const DEFAULT_DIRECT_RULES: SubJsonRule[] = [
|
|
{ type: 'field', outboundTag: 'direct', domain: ['geosite:category-ir'] },
|
|
{ type: 'field', outboundTag: 'direct', ip: ['geoip:private', 'geoip:ir'] },
|
|
];
|
|
const DEFAULT_BLOCK_RULES: SubJsonRule[] = [
|
|
{ type: 'field', outboundTag: 'block', domain: ['geosite:category-ads-all'] },
|
|
];
|
|
const BLOCK_IP_RULE: SubJsonRule = { type: 'field', outboundTag: 'block', ip: [] };
|
|
|
|
const directIPsOptions = [
|
|
{ label: 'Private IP', value: 'geoip:private' },
|
|
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
|
|
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
|
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
|
|
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
|
|
{ label: '🇪🇸 Spain', value: 'geoip:es' },
|
|
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
|
|
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
|
|
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
|
|
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
|
|
];
|
|
const directDomainsOptions = [
|
|
{ label: 'Private DNS', value: 'geosite:private' },
|
|
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
|
|
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
|
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
|
|
{ label: 'Apple', value: 'geosite:apple' },
|
|
{ label: 'Meta', value: 'geosite:meta' },
|
|
{ label: 'Google', value: 'geosite:google' },
|
|
];
|
|
const blockDomainsOptions = [
|
|
{ label: 'Ads All', value: 'geosite:category-ads-all' },
|
|
{ label: 'Adult +18', value: 'geosite:category-porn' },
|
|
];
|
|
|
|
function readJson<T>(raw: string, fallback: T): T {
|
|
try {
|
|
if (!raw) return fallback;
|
|
return JSON.parse(raw) as T;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function readRules(raw: string): SubJsonRule[] {
|
|
const parsed = readJson<unknown>(raw, null);
|
|
return Array.isArray(parsed) ? (parsed as SubJsonRule[]) : [];
|
|
}
|
|
|
|
export default function SubscriptionFormatsTab({
|
|
allSetting,
|
|
updateSetting,
|
|
}: SubscriptionFormatsTabProps) {
|
|
const { t } = useTranslation();
|
|
const { isMobile } = useMediaQuery();
|
|
|
|
const muxEnabled = allSetting.subJsonMux !== '';
|
|
|
|
const muxObj = useMemo(
|
|
() =>
|
|
muxEnabled ? readJson<typeof DEFAULT_MUX>(allSetting.subJsonMux, DEFAULT_MUX) : DEFAULT_MUX,
|
|
[allSetting.subJsonMux, muxEnabled],
|
|
);
|
|
|
|
function setMuxEnabled(v: boolean) {
|
|
updateSetting({ subJsonMux: v ? JSON.stringify(DEFAULT_MUX) : '' });
|
|
}
|
|
|
|
function setMuxField<K extends keyof typeof DEFAULT_MUX>(key: K, value: (typeof DEFAULT_MUX)[K]) {
|
|
const next = { ...muxObj, [key]: value };
|
|
updateSetting({ subJsonMux: JSON.stringify(next) });
|
|
}
|
|
|
|
const ruleArray = useMemo(() => readRules(allSetting.subJsonRules), [allSetting.subJsonRules]);
|
|
const directEnabled = ruleArray.some((r) => r.outboundTag === 'direct');
|
|
const blockEnabled = ruleArray.some((r) => r.outboundTag === 'block');
|
|
|
|
const ruleValues = (tag: string, key: 'ip' | 'domain') =>
|
|
ruleArray.find((r) => r.outboundTag === tag && r[key])?.[key] ?? [];
|
|
|
|
function writeRules(rules: SubJsonRule[]) {
|
|
updateSetting({ subJsonRules: rules.length > 0 ? JSON.stringify(rules) : '' });
|
|
}
|
|
|
|
function setTagEnabled(tag: string, defaults: SubJsonRule[], enabled: boolean) {
|
|
const rest = ruleArray.filter((r) => r.outboundTag !== tag);
|
|
if (!enabled) {
|
|
// Turning off the last managed tag also drops foreign-tag leftovers so
|
|
// the panel still has a path back to an empty subJsonRules.
|
|
const hasManaged = rest.some((r) => r.outboundTag === 'direct' || r.outboundTag === 'block');
|
|
writeRules(hasManaged ? rest : []);
|
|
return;
|
|
}
|
|
// Prepend block defaults so ads match before direct; never re-sort the rest.
|
|
writeRules(tag === 'block' ? [...defaults, ...rest] : [...rest, ...defaults]);
|
|
}
|
|
|
|
function setRuleValues(
|
|
tag: string,
|
|
key: 'ip' | 'domain',
|
|
template: SubJsonRule,
|
|
value: string[],
|
|
) {
|
|
let rules = [...ruleArray];
|
|
if (value.length === 0) {
|
|
rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
|
|
} else {
|
|
let index = rules.findIndex((r) => r.outboundTag === tag && r[key]);
|
|
if (index === -1) {
|
|
rules.push({ ...template });
|
|
index = rules.length - 1;
|
|
}
|
|
rules[index] = { ...rules[index], [key]: [...value] };
|
|
}
|
|
writeRules(rules);
|
|
}
|
|
|
|
return (
|
|
<Tabs
|
|
defaultActiveKey="1"
|
|
items={[
|
|
{
|
|
key: '1',
|
|
label: catTabLabel(
|
|
<SettingOutlined aria-hidden />,
|
|
t('pages.settings.panelSettings'),
|
|
isMobile,
|
|
),
|
|
children: (
|
|
<div className="subscription-format-sections">
|
|
{allSetting.subJsonEnable && (
|
|
<Card
|
|
size="small"
|
|
className="subscription-format-card"
|
|
title={
|
|
<span className="subscription-format-card-title">
|
|
<FileTextOutlined aria-hidden />
|
|
{t('pages.settings.subJsonEnableTitle')}
|
|
</span>
|
|
}
|
|
>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={<>JSON {t('pages.settings.subPath')}</>}
|
|
description={t('pages.settings.subPathDesc')}
|
|
>
|
|
<Input
|
|
value={allSetting.subJsonPath}
|
|
placeholder="/json/"
|
|
onChange={(e) => updateSetting({ subJsonPath: sanitizePath(e.target.value) })}
|
|
onBlur={() =>
|
|
updateSetting({ subJsonPath: normalizePath(allSetting.subJsonPath) })
|
|
}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={<>JSON {t('pages.settings.subURI')}</>}
|
|
description={t('pages.settings.subURIDesc')}
|
|
>
|
|
<Input
|
|
value={allSetting.subJsonURI}
|
|
placeholder="(http|https)://domain[:port]/path/"
|
|
onChange={(e) => updateSetting({ subJsonURI: e.target.value })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subJsonAlwaysArray')}
|
|
description={t('pages.settings.subJsonAlwaysArrayDesc')}
|
|
>
|
|
<Switch
|
|
checked={allSetting.subJsonAlwaysArray}
|
|
onChange={(value) => updateSetting({ subJsonAlwaysArray: value })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subJsonAutoDetect')}
|
|
description={t('pages.settings.subJsonAutoDetectDesc')}
|
|
>
|
|
<Switch
|
|
checked={allSetting.subJsonAutoDetect}
|
|
onChange={(v) => updateSetting({ subJsonAutoDetect: v })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subJsonUserAgentRegex')}
|
|
description={t('pages.settings.subJsonUserAgentRegexDesc')}
|
|
>
|
|
<GoRegexInput
|
|
value={allSetting.subJsonUserAgentRegex}
|
|
placeholder="(?i)^myclient([ /]|$)"
|
|
onChange={(value) => updateSetting({ subJsonUserAgentRegex: value })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subJsonRoutingRules')}
|
|
badge={remoteSourceBadge(allSetting.subJsonRoutingRules)}
|
|
description={t('pages.settings.subJsonRoutingRulesDesc')}
|
|
>
|
|
<Input.TextArea
|
|
value={allSetting.subJsonRoutingRules}
|
|
placeholder="happ://routing/onadd/... , routing JSON, or https://.../DEFAULT.JSON"
|
|
onChange={(e) => updateSetting({ subJsonRoutingRules: e.target.value })}
|
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subJsonDns')}
|
|
description={t('pages.settings.subJsonDnsDesc')}
|
|
>
|
|
<Input.TextArea
|
|
value={allSetting.subJsonDns}
|
|
placeholder='{"servers": ["https://dns.google/dns-query", "tls://1.1.1.1"]}'
|
|
onChange={(e) => updateSetting({ subJsonDns: e.target.value })}
|
|
autoSize={{ minRows: 2, maxRows: 6 }}
|
|
/>
|
|
</SettingListItem>
|
|
</Card>
|
|
)}
|
|
{allSetting.subClashEnable && (
|
|
<Card
|
|
size="small"
|
|
className="subscription-format-card"
|
|
title={
|
|
<span className="subscription-format-card-title">
|
|
<NodeIndexOutlined aria-hidden />
|
|
{t('pages.settings.subClashEnableTitle')}
|
|
</span>
|
|
}
|
|
>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={<>Clash {t('pages.settings.subPath')}</>}
|
|
description={t('pages.settings.subPathDesc')}
|
|
>
|
|
<Input
|
|
value={allSetting.subClashPath}
|
|
placeholder="/clash/"
|
|
onChange={(e) =>
|
|
updateSetting({ subClashPath: sanitizePath(e.target.value) })
|
|
}
|
|
onBlur={() =>
|
|
updateSetting({ subClashPath: normalizePath(allSetting.subClashPath) })
|
|
}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={<>Clash {t('pages.settings.subURI')}</>}
|
|
description={t('pages.settings.subURIDesc')}
|
|
>
|
|
<Input
|
|
value={allSetting.subClashURI}
|
|
placeholder="(http|https)://domain[:port]/path/"
|
|
onChange={(e) => updateSetting({ subClashURI: e.target.value })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subClashAutoDetect')}
|
|
description={t('pages.settings.subClashAutoDetectDesc')}
|
|
>
|
|
<Switch
|
|
checked={allSetting.subClashAutoDetect}
|
|
onChange={(v) => updateSetting({ subClashAutoDetect: v })}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subClashUserAgentRegex')}
|
|
description={t('pages.settings.subClashUserAgentRegexDesc')}
|
|
>
|
|
<GoRegexInput
|
|
value={allSetting.subClashUserAgentRegex}
|
|
placeholder="(?i)(clash|mihomo)"
|
|
onChange={(value) => updateSetting({ subClashUserAgentRegex: value })}
|
|
/>
|
|
</SettingListItem>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: '2',
|
|
label: catTabLabel(
|
|
<RocketOutlined aria-hidden />,
|
|
t('pages.settings.subFormats.finalMask'),
|
|
isMobile,
|
|
),
|
|
children: (
|
|
<>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subFormats.finalMask')}
|
|
description={t('pages.settings.subFormats.finalMaskDesc')}
|
|
/>
|
|
<SubJsonFinalMaskForm
|
|
value={allSetting.subJsonFinalMask}
|
|
onChange={(v) => updateSetting({ subJsonFinalMask: v })}
|
|
/>
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: '3',
|
|
label: catTabLabel(<PartitionOutlined aria-hidden />, 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="format-settings">
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subFormats.concurrency')}
|
|
>
|
|
<InputNumber
|
|
value={muxObj.concurrency}
|
|
min={-1}
|
|
max={1024}
|
|
style={{ width: '100%' }}
|
|
onChange={onNumber((v) => setMuxField('concurrency', v))}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.subFormats.xudpConcurrency')}
|
|
>
|
|
<InputNumber
|
|
value={muxObj.xudpConcurrency}
|
|
min={-1}
|
|
max={1024}
|
|
style={{ width: '100%' }}
|
|
onChange={onNumber((v) => setMuxField('xudpConcurrency', v))}
|
|
/>
|
|
</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>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: '4',
|
|
label: catTabLabel(<SendOutlined aria-hidden />, t('pages.settings.direct'), isMobile),
|
|
children: (
|
|
<>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.direct')}
|
|
description={t('pages.settings.directDesc')}
|
|
>
|
|
<Switch
|
|
checked={directEnabled}
|
|
onChange={(v) => setTagEnabled('direct', DEFAULT_DIRECT_RULES, v)}
|
|
/>
|
|
</SettingListItem>
|
|
{directEnabled && (
|
|
<div className="format-settings">
|
|
<SettingListItem paddings="small" title={<>{t('pages.settings.direct')} IPs</>}>
|
|
<Select
|
|
mode="tags"
|
|
tagRender={selectTagRender}
|
|
value={ruleValues('direct', 'ip')}
|
|
style={{ width: '100%' }}
|
|
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
|
|
options={directIPsOptions}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={
|
|
<>
|
|
{t('pages.settings.direct')} {t('domainName')}
|
|
</>
|
|
}
|
|
>
|
|
<Select
|
|
mode="tags"
|
|
tagRender={selectTagRender}
|
|
value={ruleValues('direct', 'domain')}
|
|
style={{ width: '100%' }}
|
|
onChange={(v) =>
|
|
setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
|
|
}
|
|
options={directDomainsOptions}
|
|
/>
|
|
</SettingListItem>
|
|
</div>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
{
|
|
key: '5',
|
|
label: catTabLabel(<StopOutlined aria-hidden />, t('pages.settings.block'), isMobile),
|
|
children: (
|
|
<>
|
|
<SettingListItem
|
|
paddings="small"
|
|
title={t('pages.settings.block')}
|
|
description={t('pages.settings.blockDesc')}
|
|
>
|
|
<Switch
|
|
checked={blockEnabled}
|
|
onChange={(v) => setTagEnabled('block', DEFAULT_BLOCK_RULES, v)}
|
|
/>
|
|
</SettingListItem>
|
|
{blockEnabled && (
|
|
<div className="format-settings">
|
|
<SettingListItem paddings="small" title={t('pages.xray.blockdomains')}>
|
|
<Select
|
|
mode="tags"
|
|
tagRender={selectTagRender}
|
|
value={ruleValues('block', 'domain')}
|
|
style={{ width: '100%' }}
|
|
onChange={(v) => setRuleValues('block', 'domain', DEFAULT_BLOCK_RULES[0], v)}
|
|
options={blockDomainsOptions}
|
|
/>
|
|
</SettingListItem>
|
|
<SettingListItem paddings="small" title={t('pages.xray.blockips')}>
|
|
<Select
|
|
mode="tags"
|
|
tagRender={selectTagRender}
|
|
value={ruleValues('block', 'ip')}
|
|
style={{ width: '100%' }}
|
|
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
|
|
options={directIPsOptions}
|
|
/>
|
|
</SettingListItem>
|
|
</div>
|
|
)}
|
|
</>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
);
|
|
}
|