mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-16 01:26:07 +00:00
feat(sub): auto-detect subscription format by User-Agent (Updated) (#5826)
* feat(settings): add subscription format controls
* feat(sub): auto-detect subscription formats
* fix(xray): validate balancer regexes before save
* Revert "fix(xray): validate balancer regexes before save"
This reverts commit 8a208ce71b.
* doc(endpoints): align indent spaces
* doc(settings): improve error message formatting in validateSubUserAgentRegex
- Use NewErrorf with proper formatting instead of NewError with string concatenation
- Add comment explaining the rationale for returning original pattern value
- This preserves the intentional design where empty input is stored as empty
in the DB and inherited as the runtime default at read time
---------
Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Input, Typography } from 'antd';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
interface GoRegexInputProps {
|
||||
value: string;
|
||||
ariaLabel?: string;
|
||||
placeholder?: string;
|
||||
maxLength?: number;
|
||||
onChange: (value: string) => void;
|
||||
externalError?: string;
|
||||
}
|
||||
|
||||
export async function validateGoRegex(value: string): Promise<string> {
|
||||
const result = await HttpUtil.post(
|
||||
'/panel/api/setting/validateRegex',
|
||||
{ regex: value },
|
||||
{ silent: true },
|
||||
);
|
||||
return result.success ? '' : result.msg || 'Invalid Go RE2 regular expression';
|
||||
}
|
||||
|
||||
export default function GoRegexInput({
|
||||
value,
|
||||
ariaLabel,
|
||||
placeholder,
|
||||
maxLength = 2048,
|
||||
onChange,
|
||||
externalError,
|
||||
}: GoRegexInputProps) {
|
||||
const [error, setError] = useState('');
|
||||
const validationSequence = useRef(0);
|
||||
|
||||
async function validate() {
|
||||
const sequence = ++validationSequence.current;
|
||||
const nextError = await validateGoRegex(value);
|
||||
if (sequence === validationSequence.current) {
|
||||
setError(nextError);
|
||||
}
|
||||
}
|
||||
|
||||
const displayError = externalError ?? error;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={value}
|
||||
aria-label={ariaLabel}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
status={displayError ? 'error' : undefined}
|
||||
onChange={(event) => {
|
||||
validationSequence.current += 1;
|
||||
setError('');
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onBlur={() => void validate()}
|
||||
/>
|
||||
{displayError && <Typography.Text type="danger">{displayError}</Typography.Text>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as DateTimePicker } from './DateTimePicker';
|
||||
export { default as JsonEditor } from './JsonEditor';
|
||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
|
||||
@@ -45,11 +45,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
@@ -57,12 +59,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
@@ -150,11 +155,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
@@ -162,12 +169,15 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
|
||||
@@ -150,6 +150,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -165,6 +168,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -186,6 +192,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -204,6 +216,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -365,11 +380,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -377,12 +394,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
@@ -591,6 +611,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subCertFile": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -606,6 +629,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -627,6 +653,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -645,6 +677,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonUserAgentRegex": {
|
||||
"type": "string"
|
||||
},
|
||||
"subKeyFile": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -813,11 +848,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
@@ -825,12 +862,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
"subListen",
|
||||
"subPath",
|
||||
|
||||
@@ -51,11 +51,13 @@ export interface AllSetting {
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
@@ -63,12 +65,15 @@ export interface AllSetting {
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
@@ -157,11 +162,13 @@ export interface AllSettingView {
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
@@ -169,12 +176,15 @@ export interface AllSettingView {
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
|
||||
@@ -63,11 +63,13 @@ export const AllSettingSchema = z.object({
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
@@ -75,12 +77,15 @@ export const AllSettingSchema = z.object({
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
@@ -170,11 +175,13 @@ export const AllSettingViewSchema = z.object({
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
@@ -182,12 +189,15 @@ export const AllSettingViewSchema = z.object({
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
|
||||
@@ -29,6 +29,11 @@ export class AllSetting {
|
||||
xrayTemplateConfig = '';
|
||||
subEnable = true;
|
||||
subJsonEnable = false;
|
||||
subJsonAutoDetect = false;
|
||||
subJsonAlwaysArray = false;
|
||||
subJsonUserAgentRegex = '';
|
||||
subClashAutoDetect = false;
|
||||
subClashUserAgentRegex = '';
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileUrl = '';
|
||||
|
||||
@@ -1145,7 +1145,7 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/all',
|
||||
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
@@ -1156,7 +1156,14 @@ export const sections: readonly Section[] = [
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/update',
|
||||
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "subClashAutoDetect": false,\n "subClashUserAgentRegex": "",\n "subJsonEnable": false,\n "subJsonAutoDetect": false,\n "subJsonAlwaysArray": false,\n "subJsonUserAgentRegex": "",\n "subJsonPath": "/json/",\n "subJsonURI": "https://sub.example.com/json/",\n "subClashEnable": true,\n "subClashPath": "/clash/",\n "subClashURI": "https://sub.example.com/clash/",\n "tgBotEnable": false,\n ...\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/setting/validateRegex',
|
||||
summary: 'Validate any regular expression with the backend Go RE2 compiler without saving it.',
|
||||
body: '{\n "regex": "(?m)^general-purpose$"\n}',
|
||||
response: '{\n "success": true,\n "msg": ""\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
.subscription-format-sections {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.subscription-format-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.subscription-format-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.format-settings {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
NodeIndexOutlined,
|
||||
PartitionOutlined,
|
||||
RocketOutlined,
|
||||
SendOutlined,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { GoRegexInput } from '@/components/form';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
import { sanitizePath, normalizePath } from './uriPath';
|
||||
@@ -148,9 +152,18 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
key: '1',
|
||||
label: catTabLabel(<SettingOutlined />, 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 />
|
||||
{t('pages.settings.subJsonEnableTitle')}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<SettingListItem paddings="small" title={<>JSON {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
|
||||
<Input
|
||||
value={allSetting.subJsonPath}
|
||||
@@ -166,10 +179,40 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
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>
|
||||
</Card>
|
||||
)}
|
||||
{allSetting.subClashEnable && (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
className="subscription-format-card"
|
||||
title={(
|
||||
<span className="subscription-format-card-title">
|
||||
<NodeIndexOutlined />
|
||||
{t('pages.settings.subClashEnableTitle')}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<SettingListItem paddings="small" title={<>Clash {t('pages.settings.subPath')}</>} description={t('pages.settings.subPathDesc')}>
|
||||
<Input
|
||||
value={allSetting.subClashPath}
|
||||
@@ -185,9 +228,30 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
@@ -15,6 +16,7 @@ interface SubscriptionGeneralTabProps {
|
||||
|
||||
export default function SubscriptionGeneralTab({ allSetting, updateSetting }: SubscriptionGeneralTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isMobile } = useMediaQuery();
|
||||
|
||||
return (
|
||||
@@ -33,6 +35,20 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
<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.subListen')} description={t('pages.settings.subListenDesc')}>
|
||||
<Input value={allSetting.subListen} onChange={(e) => updateSetting({ subListen: e.target.value })} />
|
||||
</SettingListItem>
|
||||
|
||||
@@ -33,6 +33,11 @@ export const AllSettingSchema = z.object({
|
||||
xrayTemplateConfig: z.string().optional(),
|
||||
subEnable: z.boolean().optional(),
|
||||
subJsonEnable: z.boolean().optional(),
|
||||
subJsonAutoDetect: z.boolean().optional(),
|
||||
subJsonAlwaysArray: z.boolean().optional(),
|
||||
subJsonUserAgentRegex: z.string().max(2048).optional(),
|
||||
subClashAutoDetect: z.boolean().optional(),
|
||||
subClashUserAgentRegex: z.string().max(2048).optional(),
|
||||
subTitle: z.string().optional(),
|
||||
subSupportUrl: z.string().optional(),
|
||||
subProfileUrl: z.string().optional(),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AllSetting } from '@/models/setting';
|
||||
import SubscriptionGeneralTab from '@/pages/settings/SubscriptionGeneralTab';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="location">{location.pathname}{location.hash}</output>;
|
||||
}
|
||||
|
||||
describe('SubscriptionGeneralTab', () => {
|
||||
it('uses router navigation to open subscription format settings', () => {
|
||||
const allSetting = new AllSetting({ subClashEnable: true });
|
||||
|
||||
renderWithProviders(
|
||||
<MemoryRouter initialEntries={['/settings#subscription']}>
|
||||
<SubscriptionGeneralTab allSetting={allSetting} updateSetting={vi.fn()} />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open Sub Formats' }));
|
||||
|
||||
expect(screen.getByTestId('location').textContent).toBe('/settings#subscription-formats');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user