mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-27 16:22:11 +08:00
On macOS, WKWebView applies system auto-capitalization to text inputs, so typing a lowercase rule payload (e.g. a domain) in the rule editor dialog turned its first letter into uppercase and required a manual correction. Apply the same input-hint attributes already used by other text fields in the app (autoComplete/autoCorrect/autoCapitalize/spellCheck) to the rule content field. Fixes #8038
844 lines
24 KiB
TypeScript
844 lines
24 KiB
TypeScript
import { arrayMove } from '@dnd-kit/helpers'
|
|
import {
|
|
VerticalAlignBottomRounded,
|
|
VerticalAlignTopRounded,
|
|
} from '@mui/icons-material'
|
|
import {
|
|
Autocomplete,
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
TextField,
|
|
styled,
|
|
} from '@mui/material'
|
|
import { useLockFn } from 'ahooks'
|
|
import * as yaml from 'js-yaml'
|
|
import {
|
|
startTransition,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import { BaseSearchBox, MonacoEditor, Switch } from '@/components/base'
|
|
import { RuleItem } from '@/components/profile/rule-item'
|
|
import { readProfileFile, saveProfileFile } from '@/services/cmds'
|
|
import { mutate } from '@/services/mutate'
|
|
import { showNotice } from '@/services/notice-service'
|
|
import { useThemeMode } from '@/services/states'
|
|
import type { TranslationKey } from '@/types/generated/i18n-keys'
|
|
import type { MonacoEditorInstance } from '@/types/monaco'
|
|
import { MONACO_FONT_FAMILY } from '@/utils/font-family'
|
|
import getSystem from '@/utils/get-system'
|
|
import { isValidIpCidr } from '@/utils/network'
|
|
import { parseYamlSafe } from '@/utils/yaml'
|
|
|
|
import {
|
|
buildGroupedItems,
|
|
type GroupedVirtualItem,
|
|
GroupedVirtualList,
|
|
} from './grouped-virtual-list'
|
|
|
|
interface Props {
|
|
groupsUid: string
|
|
mergeUid: string
|
|
profileUid: string
|
|
property: string
|
|
open: boolean
|
|
onClose: () => void
|
|
onSave?: (prev?: string, curr?: string) => void
|
|
}
|
|
|
|
const portValidator = (value: string): boolean => {
|
|
return new RegExp(
|
|
'^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$',
|
|
).test(value)
|
|
}
|
|
|
|
const rules: {
|
|
name: string
|
|
required?: boolean
|
|
example?: string
|
|
noResolve?: boolean
|
|
validator?: (value: string) => boolean
|
|
}[] = [
|
|
{
|
|
name: 'DOMAIN',
|
|
example: 'example.com',
|
|
},
|
|
{
|
|
name: 'DOMAIN-SUFFIX',
|
|
example: 'example.com',
|
|
},
|
|
{
|
|
name: 'DOMAIN-KEYWORD',
|
|
example: 'example',
|
|
},
|
|
{
|
|
name: 'DOMAIN-REGEX',
|
|
example: 'example.*',
|
|
},
|
|
{
|
|
name: 'GEOSITE',
|
|
example: 'youtube',
|
|
},
|
|
{
|
|
name: 'GEOIP',
|
|
example: 'CN',
|
|
noResolve: true,
|
|
},
|
|
{
|
|
name: 'SRC-GEOIP',
|
|
example: 'CN',
|
|
},
|
|
{
|
|
name: 'IP-ASN',
|
|
example: '13335',
|
|
noResolve: true,
|
|
validator: (value) => (+value ? true : false),
|
|
},
|
|
{
|
|
name: 'SRC-IP-ASN',
|
|
example: '9808',
|
|
validator: (value) => (+value ? true : false),
|
|
},
|
|
{
|
|
name: 'IP-CIDR',
|
|
example: '127.0.0.0/8',
|
|
noResolve: true,
|
|
validator: isValidIpCidr,
|
|
},
|
|
{
|
|
name: 'IP-CIDR6',
|
|
example: '2620:0:2d0:200::7/32',
|
|
noResolve: true,
|
|
validator: isValidIpCidr,
|
|
},
|
|
{
|
|
name: 'SRC-IP-CIDR',
|
|
example: '192.168.1.201/32',
|
|
validator: isValidIpCidr,
|
|
},
|
|
{
|
|
name: 'IP-SUFFIX',
|
|
example: '8.8.8.8/24',
|
|
noResolve: true,
|
|
validator: isValidIpCidr,
|
|
},
|
|
{
|
|
name: 'SRC-IP-SUFFIX',
|
|
example: '192.168.1.201/8',
|
|
validator: isValidIpCidr,
|
|
},
|
|
{
|
|
name: 'SRC-PORT',
|
|
example: '7777',
|
|
validator: (value) => portValidator(value),
|
|
},
|
|
{
|
|
name: 'DST-PORT',
|
|
example: '80',
|
|
validator: (value) => portValidator(value),
|
|
},
|
|
{
|
|
name: 'IN-PORT',
|
|
example: '7897',
|
|
validator: (value) => portValidator(value),
|
|
},
|
|
{
|
|
name: 'DSCP',
|
|
example: '4',
|
|
},
|
|
{
|
|
name: 'PROCESS-NAME',
|
|
example: getSystem() === 'windows' ? 'chrome.exe' : 'curl',
|
|
},
|
|
{
|
|
name: 'PROCESS-PATH',
|
|
example:
|
|
getSystem() === 'windows'
|
|
? 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
|
|
: '/usr/bin/wget',
|
|
},
|
|
{
|
|
name: 'PROCESS-NAME-REGEX',
|
|
example: '.*telegram.*',
|
|
},
|
|
{
|
|
name: 'PROCESS-PATH-REGEX',
|
|
example:
|
|
getSystem() === 'windows' ? '(?i).*Application\\chrome.*' : '.*bin/wget',
|
|
},
|
|
{
|
|
name: 'NETWORK',
|
|
example: 'udp',
|
|
validator: (value) => ['tcp', 'udp'].includes(value),
|
|
},
|
|
{
|
|
name: 'UID',
|
|
example: '1001',
|
|
validator: (value) => (+value ? true : false),
|
|
},
|
|
{
|
|
name: 'IN-TYPE',
|
|
example: 'SOCKS/HTTP',
|
|
},
|
|
{
|
|
name: 'IN-USER',
|
|
example: 'mihomo',
|
|
},
|
|
{
|
|
name: 'IN-NAME',
|
|
example: 'ss',
|
|
},
|
|
{
|
|
name: 'SUB-RULE',
|
|
example: '(NETWORK,tcp)',
|
|
},
|
|
{
|
|
name: 'RULE-SET',
|
|
example: 'providername',
|
|
noResolve: true,
|
|
},
|
|
{
|
|
name: 'AND',
|
|
example: '((DOMAIN,baidu.com),(NETWORK,UDP))',
|
|
},
|
|
{
|
|
name: 'OR',
|
|
example: '((NETWORK,UDP),(DOMAIN,baidu.com))',
|
|
},
|
|
{
|
|
name: 'NOT',
|
|
example: '((DOMAIN,baidu.com))',
|
|
},
|
|
{
|
|
name: 'MATCH',
|
|
required: false,
|
|
},
|
|
]
|
|
|
|
const RULE_TYPE_LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
|
rules.map((rule) => [
|
|
rule.name,
|
|
`rules.modals.editor.ruleTypes.${rule.name}`,
|
|
]),
|
|
)
|
|
|
|
const builtinProxyPolicies = ['DIRECT', 'REJECT', 'REJECT-DROP', 'PASS']
|
|
|
|
const PROXY_POLICY_LABEL_KEYS: Record<string, TranslationKey> =
|
|
builtinProxyPolicies.reduce(
|
|
(acc, policy) => {
|
|
acc[policy] =
|
|
`proxies.components.enums.policies.${policy}` as TranslationKey
|
|
return acc
|
|
},
|
|
{} as Record<string, TranslationKey>,
|
|
)
|
|
|
|
const findRealIndex = (
|
|
list: string[],
|
|
filtered: string[],
|
|
filteredIndex: number,
|
|
): number => {
|
|
const item = filtered[filteredIndex]
|
|
if (item === undefined) return -1
|
|
return list.indexOf(item)
|
|
}
|
|
|
|
export const RulesEditorViewer = (props: Props) => {
|
|
const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } =
|
|
props
|
|
const { t } = useTranslation()
|
|
const themeMode = useThemeMode()
|
|
|
|
const editorRef = useRef<MonacoEditorInstance | null>(null)
|
|
|
|
const [prevData, setPrevData] = useState('')
|
|
const [currData, setCurrData] = useState('')
|
|
const [visualization, setVisualization] = useState(true)
|
|
const [match, setMatch] = useState(() => (_: string) => true)
|
|
|
|
const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0])
|
|
const [ruleContent, setRuleContent] = useState('')
|
|
const [noResolve, setNoResolve] = useState(false)
|
|
const [proxyPolicy, setProxyPolicy] = useState(builtinProxyPolicies[0])
|
|
const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([])
|
|
const [ruleList, setRuleList] = useState<string[]>([])
|
|
const [ruleSetList, setRuleSetList] = useState<string[]>([])
|
|
const [subRuleList, setSubRuleList] = useState<string[]>([])
|
|
|
|
const [prependSeq, setPrependSeq] = useState<string[]>([])
|
|
const [appendSeq, setAppendSeq] = useState<string[]>([])
|
|
const [deleteSeq, setDeleteSeq] = useState<string[]>([])
|
|
const hasLoadedSeqConfigRef = useRef(false)
|
|
|
|
const filteredPrependSeq = useMemo(
|
|
() => prependSeq.filter((rule) => match(rule)),
|
|
[prependSeq, match],
|
|
)
|
|
const filteredRuleList = useMemo(
|
|
() => ruleList.filter((rule) => match(rule)),
|
|
[ruleList, match],
|
|
)
|
|
const filteredAppendSeq = useMemo(
|
|
() => appendSeq.filter((rule) => match(rule)),
|
|
[appendSeq, match],
|
|
)
|
|
|
|
const items = useMemo(
|
|
() =>
|
|
buildGroupedItems(
|
|
filteredPrependSeq,
|
|
filteredRuleList,
|
|
filteredAppendSeq,
|
|
(rule) => rule,
|
|
),
|
|
[filteredPrependSeq, filteredRuleList, filteredAppendSeq],
|
|
)
|
|
|
|
const renderItem = (entry: GroupedVirtualItem<string>) => {
|
|
const { category, item } = entry
|
|
|
|
if (category === 'original') {
|
|
const isDeleted = deleteSeq.includes(item)
|
|
return (
|
|
<RuleItem
|
|
type={isDeleted ? 'delete' : 'original'}
|
|
ruleRaw={item}
|
|
onDelete={() => {
|
|
if (isDeleted) {
|
|
setDeleteSeq(deleteSeq.filter((v) => v !== item))
|
|
} else {
|
|
setDeleteSeq((prev) => [...prev, item])
|
|
}
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
if (category === 'prepend') {
|
|
return (
|
|
<RuleItem
|
|
type="prepend"
|
|
ruleRaw={item}
|
|
onDelete={() => {
|
|
setPrependSeq(prependSeq.filter((v) => v !== item))
|
|
}}
|
|
onAppend={() => {
|
|
setAppendSeq((prev) =>
|
|
prev.includes(item) ? prev : [...prev, item],
|
|
)
|
|
setPrependSeq(prependSeq.filter((v) => v !== item))
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<RuleItem
|
|
type="append"
|
|
ruleRaw={item}
|
|
onDelete={() => {
|
|
setAppendSeq(appendSeq.filter((v) => v !== item))
|
|
}}
|
|
onPrepend={() => {
|
|
setPrependSeq((prev) =>
|
|
prev.includes(item) ? prev : [...prev, item],
|
|
)
|
|
setAppendSeq(appendSeq.filter((v) => v !== item))
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const onReorder = (
|
|
category: 'prepend' | 'append',
|
|
activeIndex: number,
|
|
overIndex: number,
|
|
) => {
|
|
const list = category === 'prepend' ? prependSeq : appendSeq
|
|
const filtered =
|
|
category === 'prepend' ? filteredPrependSeq : filteredAppendSeq
|
|
const setList = category === 'prepend' ? setPrependSeq : setAppendSeq
|
|
const activeRealIndex = findRealIndex(list, filtered, activeIndex)
|
|
const overRealIndex = findRealIndex(list, filtered, overIndex)
|
|
if (
|
|
activeRealIndex < 0 ||
|
|
overRealIndex < 0 ||
|
|
activeRealIndex === overRealIndex
|
|
) {
|
|
return
|
|
}
|
|
|
|
setList(arrayMove(list, activeRealIndex, overRealIndex))
|
|
}
|
|
|
|
const fetchContent = useCallback(async () => {
|
|
hasLoadedSeqConfigRef.current = false
|
|
const data = await readProfileFile(property)
|
|
const obj = parseYamlSafe(data) as ISeqProfileConfig | null | undefined
|
|
|
|
setPrevData(data)
|
|
setCurrData(data)
|
|
|
|
if (obj === undefined) {
|
|
setVisualization(false)
|
|
return
|
|
}
|
|
|
|
setPrependSeq(obj?.prepend || [])
|
|
setAppendSeq(obj?.append || [])
|
|
setDeleteSeq(obj?.delete || [])
|
|
hasLoadedSeqConfigRef.current = true
|
|
}, [property])
|
|
|
|
const handleVisualizationToggle = () => {
|
|
if (visualization) {
|
|
setVisualization(false)
|
|
return
|
|
}
|
|
|
|
const obj = parseYamlSafe(currData) as ISeqProfileConfig | null | undefined
|
|
if (obj === undefined) {
|
|
hasLoadedSeqConfigRef.current = false
|
|
return
|
|
}
|
|
|
|
hasLoadedSeqConfigRef.current = true
|
|
startTransition(() => {
|
|
setPrependSeq(obj?.prepend ?? [])
|
|
setAppendSeq(obj?.append ?? [])
|
|
setDeleteSeq(obj?.delete ?? [])
|
|
})
|
|
setVisualization(true)
|
|
}
|
|
|
|
// 优化:异步处理大数据yaml.dump,避免UI卡死
|
|
useEffect(() => {
|
|
if (!hasLoadedSeqConfigRef.current) {
|
|
return
|
|
}
|
|
|
|
if (!(prependSeq && appendSeq && deleteSeq)) {
|
|
return
|
|
}
|
|
|
|
const serialize = () => {
|
|
if (!hasLoadedSeqConfigRef.current) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
setCurrData(
|
|
yaml.dump(
|
|
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
|
|
{ forceQuotes: true },
|
|
),
|
|
)
|
|
} catch (error) {
|
|
showNotice.error(error ?? 'YAML dump error')
|
|
}
|
|
}
|
|
let idleId: number | undefined
|
|
let timeoutId: number | undefined
|
|
if (window.requestIdleCallback) {
|
|
idleId = window.requestIdleCallback(serialize)
|
|
} else {
|
|
timeoutId = window.setTimeout(serialize, 0)
|
|
}
|
|
return () => {
|
|
if (idleId !== undefined && window.cancelIdleCallback) {
|
|
window.cancelIdleCallback(idleId)
|
|
}
|
|
if (timeoutId !== undefined) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
}, [prependSeq, appendSeq, deleteSeq])
|
|
|
|
const fetchProfile = useCallback(async () => {
|
|
const data = await readProfileFile(profileUid) // 原配置文件
|
|
const groupsData = await readProfileFile(groupsUid) // groups配置文件
|
|
const mergeData = await readProfileFile(mergeUid) // merge配置文件
|
|
const globalMergeData = await readProfileFile('Merge') // global merge配置文件
|
|
|
|
const rulesObj = parseYamlSafe(data) as { rules: [] } | null
|
|
|
|
const originGroupsObj = parseYamlSafe(data) as {
|
|
'proxy-groups': IProxyGroupConfig[]
|
|
} | null
|
|
const originGroups = originGroupsObj?.['proxy-groups'] || []
|
|
const moreGroupsObj = parseYamlSafe(groupsData) as ISeqProfileConfig | null
|
|
const rawPrependGroups = moreGroupsObj?.['prepend']
|
|
const morePrependGroups = Array.isArray(rawPrependGroups)
|
|
? (rawPrependGroups as IProxyGroupConfig[])
|
|
: []
|
|
const rawAppendGroups = moreGroupsObj?.['append']
|
|
const moreAppendGroups = Array.isArray(rawAppendGroups)
|
|
? (rawAppendGroups as IProxyGroupConfig[])
|
|
: []
|
|
const rawDeleteGroups = moreGroupsObj?.['delete']
|
|
const moreDeleteGroups: Array<string | { name: string }> = Array.isArray(
|
|
rawDeleteGroups,
|
|
)
|
|
? (rawDeleteGroups as Array<string | { name: string }>)
|
|
: []
|
|
const groups = morePrependGroups.concat(
|
|
originGroups.filter((group: any) => {
|
|
if (group.name) {
|
|
return !moreDeleteGroups.includes(group.name)
|
|
} else {
|
|
return !moreDeleteGroups.includes(group)
|
|
}
|
|
}),
|
|
moreAppendGroups,
|
|
)
|
|
|
|
const originRuleSetObj = parseYamlSafe(data) as {
|
|
'rule-providers': Record<string, unknown>
|
|
} | null
|
|
const originRuleSet = originRuleSetObj?.['rule-providers'] || {}
|
|
const moreRuleSetObj = parseYamlSafe(mergeData) as {
|
|
'rule-providers': Record<string, unknown>
|
|
} | null
|
|
const moreRuleSet = moreRuleSetObj?.['rule-providers'] || {}
|
|
const globalRuleSetObj = parseYamlSafe(globalMergeData) as {
|
|
'rule-providers': Record<string, unknown>
|
|
} | null
|
|
const globalRuleSet = globalRuleSetObj?.['rule-providers'] || {}
|
|
const ruleSet = Object.assign({}, originRuleSet, moreRuleSet, globalRuleSet)
|
|
|
|
const originSubRuleObj = parseYamlSafe(data) as {
|
|
'sub-rules': Record<string, unknown>
|
|
} | null
|
|
const originSubRule = originSubRuleObj?.['sub-rules'] || {}
|
|
const moreSubRuleObj = parseYamlSafe(mergeData) as {
|
|
'sub-rules': Record<string, unknown>
|
|
} | null
|
|
const moreSubRule = moreSubRuleObj?.['sub-rules'] || {}
|
|
const globalSubRuleObj = parseYamlSafe(globalMergeData) as {
|
|
'sub-rules': Record<string, unknown>
|
|
} | null
|
|
const globalSubRule = globalSubRuleObj?.['sub-rules'] || {}
|
|
const subRule = Object.assign({}, originSubRule, moreSubRule, globalSubRule)
|
|
setProxyPolicyList(
|
|
builtinProxyPolicies.concat(groups.map((group: any) => group.name)),
|
|
)
|
|
setRuleSetList(Object.keys(ruleSet))
|
|
setSubRuleList(Object.keys(subRule))
|
|
setRuleList(rulesObj?.rules || [])
|
|
}, [groupsUid, mergeUid, profileUid])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
fetchContent()
|
|
fetchProfile()
|
|
}, [fetchContent, fetchProfile, open])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
editorRef.current?.dispose()
|
|
editorRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
const validateRule = () => {
|
|
if ((ruleType.required ?? true) && !ruleContent) {
|
|
throw new Error(
|
|
t('rules.modals.editor.form.validation.conditionRequired'),
|
|
)
|
|
}
|
|
if (ruleType.validator && !ruleType.validator(ruleContent)) {
|
|
throw new Error(t('rules.modals.editor.form.validation.invalidRule'))
|
|
}
|
|
|
|
const condition = (ruleType.required ?? true) ? ruleContent : ''
|
|
return `${ruleType.name}${condition ? ',' + condition : ''},${proxyPolicy}${
|
|
ruleType.noResolve && noResolve ? ',no-resolve' : ''
|
|
}`
|
|
}
|
|
|
|
const handleSave = useLockFn(async () => {
|
|
try {
|
|
const result = await mutate(() => saveProfileFile(property, currData), {
|
|
id: `save-profile-file:${property}`,
|
|
errorNotice: false,
|
|
})
|
|
if (!(result.ok && result.value === true)) {
|
|
await fetchContent()
|
|
onClose()
|
|
return
|
|
}
|
|
showNotice.success('shared.feedback.notifications.saved')
|
|
onSave?.(prevData, currData)
|
|
onClose()
|
|
} catch (err: any) {
|
|
showNotice.error(err)
|
|
}
|
|
})
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
maxWidth="xl"
|
|
fullWidth
|
|
disableEnforceFocus={!visualization}
|
|
>
|
|
<DialogTitle>
|
|
{
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
{t('rules.modals.editor.title')}
|
|
<Box>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
onClick={handleVisualizationToggle}
|
|
>
|
|
{visualization
|
|
? t('shared.editorModes.advanced')
|
|
: t('shared.editorModes.visualization')}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
}
|
|
</DialogTitle>
|
|
|
|
<DialogContent
|
|
sx={{ display: 'flex', width: 'auto', height: 'calc(100vh - 185px)' }}
|
|
>
|
|
{visualization ? (
|
|
<>
|
|
<List
|
|
sx={{
|
|
width: '50%',
|
|
padding: '0 10px',
|
|
}}
|
|
>
|
|
<Item>
|
|
<ListItemText
|
|
primary={t('rules.modals.editor.form.labels.type')}
|
|
/>
|
|
<Autocomplete
|
|
size="small"
|
|
sx={{ minWidth: '240px' }}
|
|
renderInput={(params) => <TextField {...params} />}
|
|
options={rules}
|
|
value={ruleType}
|
|
getOptionLabel={(option) =>
|
|
t(RULE_TYPE_LABEL_KEYS[option.name] ?? option.name)
|
|
}
|
|
renderOption={(props, option) => {
|
|
const { key, ...optionProps } = props
|
|
const label = t(
|
|
RULE_TYPE_LABEL_KEYS[option.name] ?? option.name,
|
|
)
|
|
return (
|
|
<li key={key} {...optionProps} title={label}>
|
|
{label}
|
|
</li>
|
|
)
|
|
}}
|
|
onChange={(_, value) => value && setRuleType(value)}
|
|
/>
|
|
</Item>
|
|
<Item
|
|
sx={{ display: !(ruleType.required ?? true) ? 'none' : '' }}
|
|
>
|
|
<ListItemText
|
|
primary={t('rules.modals.editor.form.labels.content')}
|
|
/>
|
|
|
|
{ruleType.name === 'RULE-SET' && (
|
|
<Autocomplete
|
|
size="small"
|
|
sx={{ minWidth: '240px' }}
|
|
renderInput={(params) => <TextField {...params} />}
|
|
options={ruleSetList}
|
|
value={ruleContent}
|
|
onChange={(_, value) => value && setRuleContent(value)}
|
|
/>
|
|
)}
|
|
{ruleType.name === 'SUB-RULE' && (
|
|
<Autocomplete
|
|
size="small"
|
|
sx={{ minWidth: '240px' }}
|
|
renderInput={(params) => <TextField {...params} />}
|
|
options={subRuleList}
|
|
value={ruleContent}
|
|
onChange={(_, value) => value && setRuleContent(value)}
|
|
/>
|
|
)}
|
|
{ruleType.name !== 'RULE-SET' &&
|
|
ruleType.name !== 'SUB-RULE' && (
|
|
<TextField
|
|
autoComplete="new-password"
|
|
autoCorrect="off"
|
|
autoCapitalize="off"
|
|
spellCheck="false"
|
|
size="small"
|
|
sx={{ minWidth: '240px' }}
|
|
slotProps={{
|
|
htmlInput: {
|
|
autoCorrect: 'off',
|
|
autoCapitalize: 'off',
|
|
spellCheck: false,
|
|
},
|
|
}}
|
|
value={ruleContent}
|
|
required={ruleType.required ?? true}
|
|
error={(ruleType.required ?? true) && !ruleContent}
|
|
placeholder={ruleType.example}
|
|
onChange={(e) => setRuleContent(e.target.value)}
|
|
/>
|
|
)}
|
|
</Item>
|
|
<Item>
|
|
<ListItemText
|
|
primary={t('rules.modals.editor.form.labels.proxyPolicy')}
|
|
/>
|
|
<Autocomplete
|
|
size="small"
|
|
sx={{ minWidth: '240px' }}
|
|
renderInput={(params) => <TextField {...params} />}
|
|
options={proxyPolicyList}
|
|
value={proxyPolicy}
|
|
getOptionLabel={(option) =>
|
|
t(PROXY_POLICY_LABEL_KEYS[option] ?? option)
|
|
}
|
|
renderOption={(props, option) => {
|
|
const { key, ...optionProps } = props
|
|
const label = t(PROXY_POLICY_LABEL_KEYS[option] ?? option)
|
|
return (
|
|
<li key={key} {...optionProps} title={label}>
|
|
{label}
|
|
</li>
|
|
)
|
|
}}
|
|
onChange={(_, value) => value && setProxyPolicy(value)}
|
|
/>
|
|
</Item>
|
|
{ruleType.noResolve && (
|
|
<Item>
|
|
<ListItemText
|
|
primary={t('rules.modals.editor.form.toggles.noResolve')}
|
|
/>
|
|
<Switch
|
|
checked={noResolve}
|
|
onChange={() => setNoResolve(!noResolve)}
|
|
/>
|
|
</Item>
|
|
)}
|
|
<Item>
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
startIcon={<VerticalAlignTopRounded />}
|
|
onClick={() => {
|
|
try {
|
|
const raw = validateRule()
|
|
if (prependSeq.includes(raw)) return
|
|
setPrependSeq([raw, ...prependSeq])
|
|
} catch (err: any) {
|
|
showNotice.error(err)
|
|
}
|
|
}}
|
|
>
|
|
{t('rules.modals.editor.form.actions.prependRule')}
|
|
</Button>
|
|
</Item>
|
|
<Item>
|
|
<Button
|
|
fullWidth
|
|
variant="contained"
|
|
startIcon={<VerticalAlignBottomRounded />}
|
|
onClick={() => {
|
|
try {
|
|
const raw = validateRule()
|
|
if (appendSeq.includes(raw)) return
|
|
setAppendSeq([...appendSeq, raw])
|
|
} catch (err: any) {
|
|
showNotice.error(err)
|
|
}
|
|
}}
|
|
>
|
|
{t('rules.modals.editor.form.actions.appendRule')}
|
|
</Button>
|
|
</Item>
|
|
</List>
|
|
|
|
<List
|
|
sx={{
|
|
width: '50%',
|
|
padding: '0 10px',
|
|
}}
|
|
>
|
|
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
|
<GroupedVirtualList
|
|
items={items}
|
|
renderItem={renderItem}
|
|
onReorder={onReorder}
|
|
style={{ height: 'calc(100% - 24px)', marginTop: '8px' }}
|
|
/>
|
|
</List>
|
|
</>
|
|
) : (
|
|
<MonacoEditor
|
|
height="100%"
|
|
language="yaml"
|
|
value={currData}
|
|
theme={themeMode === 'light' ? 'light' : 'vs-dark'}
|
|
onMount={(editorInstance) => {
|
|
editorRef.current = editorInstance
|
|
}}
|
|
options={{
|
|
tabSize: 2, // 根据语言类型设置缩进大小
|
|
minimap: {
|
|
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
|
|
},
|
|
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
|
|
quickSuggestions: {
|
|
strings: true, // 字符串类型的建议
|
|
comments: true, // 注释类型的建议
|
|
other: true, // 其他类型的建议
|
|
},
|
|
padding: {
|
|
top: 33, // 顶部padding防止遮挡snippets
|
|
},
|
|
fontFamily: MONACO_FONT_FAMILY,
|
|
fontLigatures: false, // 连字符
|
|
smoothScrolling: true, // 平滑滚动
|
|
}}
|
|
onChange={(value) => setCurrData(value ?? '')}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<DialogActions>
|
|
<Button onClick={onClose} variant="outlined">
|
|
{t('shared.actions.cancel')}
|
|
</Button>
|
|
|
|
<Button onClick={handleSave} variant="contained">
|
|
{t('shared.actions.save')}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
const Item = styled(ListItem)(() => ({
|
|
padding: '5px 2px',
|
|
}))
|