mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
feat(settings): add Block tab for JSON subscription routing rules (#6466)
* feat(settings): add Block tab for JSON subscription routing rules Expose the existing blackhole outbound in the subscription formats UI so operators can add block domain/IP rules without editing subJsonRules by hand. Scope Direct/Block helpers by outboundTag so the tabs keep separate rule objects, and keep block rules ahead of direct for Xray match order. * fix(settings): preserve rule order and clear foreign leftovers Stop sorting the whole subJsonRules array on every write. Prepend block defaults only when enabling Block. Clearing the last managed tag also drops foreign-tag leftovers so the panel can reach an empty setting. Fixes oxfmt on SubscriptionFormatsTab. --------- Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
RocketOutlined,
|
||||
SendOutlined,
|
||||
SettingOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
@@ -31,10 +32,17 @@ const DEFAULT_MUX = {
|
||||
xudpConcurrency: 16,
|
||||
xudpProxyUDP443: 'reject',
|
||||
};
|
||||
const DEFAULT_RULES: { type: string; outboundTag: string; domain?: string[]; ip?: string[] }[] = [
|
||||
|
||||
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' },
|
||||
@@ -57,6 +65,10 @@ const directDomainsOptions = [
|
||||
{ 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 {
|
||||
@@ -67,6 +79,11 @@ function readJson<T>(raw: string, fallback: T): T {
|
||||
}
|
||||
}
|
||||
|
||||
function readRules(raw: string): SubJsonRule[] {
|
||||
const parsed = readJson<unknown>(raw, null);
|
||||
return Array.isArray(parsed) ? (parsed as SubJsonRule[]) : [];
|
||||
}
|
||||
|
||||
export default function SubscriptionFormatsTab({
|
||||
allSetting,
|
||||
updateSetting,
|
||||
@@ -75,7 +92,6 @@ export default function SubscriptionFormatsTab({
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
const muxEnabled = allSetting.subJsonMux !== '';
|
||||
const directEnabled = allSetting.subJsonRules !== '';
|
||||
|
||||
const muxObj = useMemo(
|
||||
() =>
|
||||
@@ -92,57 +108,48 @@ export default function SubscriptionFormatsTab({
|
||||
updateSetting({ subJsonMux: JSON.stringify(next) });
|
||||
}
|
||||
|
||||
const ruleArray = useMemo(() => {
|
||||
if (!directEnabled) return null;
|
||||
return readJson<typeof DEFAULT_RULES | null>(allSetting.subJsonRules, null);
|
||||
}, [allSetting.subJsonRules, directEnabled]);
|
||||
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 directIPs = useMemo(() => {
|
||||
if (!ruleArray) return [];
|
||||
const ipRule = ruleArray.find((r) => r.ip);
|
||||
return ipRule?.ip ?? [];
|
||||
}, [ruleArray]);
|
||||
const ruleValues = (tag: string, key: 'ip' | 'domain') =>
|
||||
ruleArray.find((r) => r.outboundTag === tag && r[key])?.[key] ?? [];
|
||||
|
||||
const directDomains = useMemo(() => {
|
||||
if (!ruleArray) return [];
|
||||
const dRule = ruleArray.find((r) => r.domain);
|
||||
return dRule?.domain ?? [];
|
||||
}, [ruleArray]);
|
||||
|
||||
function setDirectEnabled(v: boolean) {
|
||||
updateSetting({ subJsonRules: v ? JSON.stringify(DEFAULT_RULES) : '' });
|
||||
function writeRules(rules: SubJsonRule[]) {
|
||||
updateSetting({ subJsonRules: rules.length > 0 ? JSON.stringify(rules) : '' });
|
||||
}
|
||||
|
||||
function setDirectIPs(value: string[]) {
|
||||
if (!ruleArray) return;
|
||||
let rules = [...ruleArray];
|
||||
if (value.length === 0) {
|
||||
rules = rules.filter((r) => !r.ip);
|
||||
} else {
|
||||
let idx = rules.findIndex((r) => r.ip);
|
||||
if (idx === -1) {
|
||||
rules.push({ ...DEFAULT_RULES[1] });
|
||||
idx = rules.length - 1;
|
||||
}
|
||||
rules[idx] = { ...rules[idx], ip: [...value] };
|
||||
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;
|
||||
}
|
||||
updateSetting({ subJsonRules: JSON.stringify(rules) });
|
||||
// Prepend block defaults so ads match before direct; never re-sort the rest.
|
||||
writeRules(tag === 'block' ? [...defaults, ...rest] : [...rest, ...defaults]);
|
||||
}
|
||||
|
||||
function setDirectDomains(value: string[]) {
|
||||
if (!ruleArray) return;
|
||||
function setRuleValues(
|
||||
tag: string,
|
||||
key: 'ip' | 'domain',
|
||||
template: SubJsonRule,
|
||||
value: string[],
|
||||
) {
|
||||
let rules = [...ruleArray];
|
||||
if (value.length === 0) {
|
||||
rules = rules.filter((r) => !r.domain);
|
||||
rules = rules.filter((r) => !(r.outboundTag === tag && r[key]));
|
||||
} else {
|
||||
let idx = rules.findIndex((r) => r.domain);
|
||||
if (idx === -1) {
|
||||
rules.push({ ...DEFAULT_RULES[0] });
|
||||
idx = rules.length - 1;
|
||||
let index = rules.findIndex((r) => r.outboundTag === tag && r[key]);
|
||||
if (index === -1) {
|
||||
rules.push({ ...template });
|
||||
index = rules.length - 1;
|
||||
}
|
||||
rules[idx] = { ...rules[idx], domain: [...value] };
|
||||
rules[index] = { ...rules[index], [key]: [...value] };
|
||||
}
|
||||
updateSetting({ subJsonRules: JSON.stringify(rules) });
|
||||
writeRules(rules);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -385,16 +392,19 @@ export default function SubscriptionFormatsTab({
|
||||
title={t('pages.settings.direct')}
|
||||
description={t('pages.settings.directDesc')}
|
||||
>
|
||||
<Switch checked={directEnabled} onChange={setDirectEnabled} />
|
||||
<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"
|
||||
value={directIPs}
|
||||
value={ruleValues('direct', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={setDirectIPs}
|
||||
onChange={(v) => setRuleValues('direct', 'ip', DEFAULT_DIRECT_RULES[1], v)}
|
||||
options={directIPsOptions}
|
||||
/>
|
||||
</SettingListItem>
|
||||
@@ -408,9 +418,11 @@ export default function SubscriptionFormatsTab({
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={directDomains}
|
||||
value={ruleValues('direct', 'domain')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={setDirectDomains}
|
||||
onChange={(v) =>
|
||||
setRuleValues('direct', 'domain', DEFAULT_DIRECT_RULES[0], v)
|
||||
}
|
||||
options={directDomainsOptions}
|
||||
/>
|
||||
</SettingListItem>
|
||||
@@ -419,6 +431,46 @@ export default function SubscriptionFormatsTab({
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
label: catTabLabel(<StopOutlined />, 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"
|
||||
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"
|
||||
value={ruleValues('block', 'ip')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => setRuleValues('block', 'ip', BLOCK_IP_RULE, v)}
|
||||
options={directIPsOptions}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user