mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2025-12-26 20:50:30 +08:00
feat: add visual rule editor (#1251)
* feat: add visual rule editor * Support MATCH * Support Additional Parameters * Use Trash Icon * Support sorting * Support adding prepend/append rules * IoIosArrow -> IoMdArrow * More checks * Support delete with undo
This commit is contained in:
parent
6429e93adf
commit
2bf54446df
596
src/renderer/src/components/profiles/edit-rules-modal.tsx
Normal file
596
src/renderer/src/components/profiles/edit-rules-modal.tsx
Normal file
@ -0,0 +1,596 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalBody,
|
||||||
|
ModalFooter,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteItem,
|
||||||
|
Checkbox,
|
||||||
|
Divider
|
||||||
|
} from '@heroui/react'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import yaml from 'js-yaml'
|
||||||
|
import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io'
|
||||||
|
import { MdVerticalAlignTop, MdVerticalAlignBottom } from 'react-icons/md'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RuleItem {
|
||||||
|
type: string
|
||||||
|
payload: string
|
||||||
|
proxy: string
|
||||||
|
additionalParams?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内置路由规则 https://wiki.metacubex.one/config/rules/
|
||||||
|
const ruleTypes = [
|
||||||
|
'DOMAIN',
|
||||||
|
'DOMAIN-SUFFIX',
|
||||||
|
'DOMAIN-KEYWORD',
|
||||||
|
'DOMAIN-WILDCARD',
|
||||||
|
'DOMAIN-REGEX',
|
||||||
|
'GEOSITE',
|
||||||
|
'IP-CIDR',
|
||||||
|
'IP-CIDR6',
|
||||||
|
'IP-SUFFIX',
|
||||||
|
'IP-ASN',
|
||||||
|
'GEOIP',
|
||||||
|
'SRC-GEOIP',
|
||||||
|
'SRC-IP-ASN',
|
||||||
|
'SRC-IP-CIDR',
|
||||||
|
'SRC-IP-SUFFIX',
|
||||||
|
'DST-PORT',
|
||||||
|
'SRC-PORT',
|
||||||
|
'IN-PORT',
|
||||||
|
'IN-TYPE',
|
||||||
|
'IN-USER',
|
||||||
|
'IN-NAME',
|
||||||
|
'PROCESS-PATH',
|
||||||
|
'PROCESS-PATH-REGEX',
|
||||||
|
'PROCESS-NAME',
|
||||||
|
'PROCESS-NAME-REGEX',
|
||||||
|
'UID',
|
||||||
|
'NETWORK',
|
||||||
|
'DSCP',
|
||||||
|
'RULE-SET',
|
||||||
|
'AND',
|
||||||
|
'OR',
|
||||||
|
'NOT',
|
||||||
|
'SUB-RULE',
|
||||||
|
'MATCH'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 支持 no-resolve 参数的规则类型
|
||||||
|
const noResolveSupportTypes = [
|
||||||
|
'IP-CIDR',
|
||||||
|
'IP-CIDR6',
|
||||||
|
'IP-SUFFIX',
|
||||||
|
'IP-ASN',
|
||||||
|
'GEOIP'
|
||||||
|
]
|
||||||
|
|
||||||
|
// 支持 src 参数的规则类型
|
||||||
|
const srcSupportTypes = [
|
||||||
|
'IP-CIDR',
|
||||||
|
'IP-CIDR6',
|
||||||
|
'IP-SUFFIX',
|
||||||
|
'IP-ASN',
|
||||||
|
'GEOIP'
|
||||||
|
]
|
||||||
|
|
||||||
|
const EditRulesModal: React.FC<Props> = (props) => {
|
||||||
|
const { id, onClose } = props
|
||||||
|
const [rules, setRules] = useState<RuleItem[]>([])
|
||||||
|
const [filteredRules, setFilteredRules] = useState<RuleItem[]>([])
|
||||||
|
const [profileContent, setProfileContent] = useState('')
|
||||||
|
const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
|
||||||
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
|
const [proxyGroups, setProxyGroups] = useState<string[]>([])
|
||||||
|
const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set()) // Store indices of deleted rules
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const getContent = async (): Promise<void> => {
|
||||||
|
const content = await getProfileStr(id)
|
||||||
|
setProfileContent(content)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = yaml.load(content) as any
|
||||||
|
if (parsed && parsed.rules && Array.isArray(parsed.rules)) {
|
||||||
|
const parsedRules = parsed.rules.map((rule: string) => {
|
||||||
|
const parts = rule.split(',')
|
||||||
|
if (parts[0] === 'MATCH') {
|
||||||
|
return {
|
||||||
|
type: 'MATCH',
|
||||||
|
proxy: parts[1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const additionalParams = parts.slice(3).filter(param => param.trim() !== '') || []
|
||||||
|
return {
|
||||||
|
type: parts[0],
|
||||||
|
payload: parts[1],
|
||||||
|
proxy: parts[2],
|
||||||
|
additionalParams
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setRules(parsedRules)
|
||||||
|
setFilteredRules(parsedRules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从配置文件中提取代理组
|
||||||
|
if (parsed) {
|
||||||
|
const groups: string[] = []
|
||||||
|
|
||||||
|
// 添加代理组名称
|
||||||
|
if (Array.isArray(parsed['proxy-groups'])) {
|
||||||
|
groups.push(...parsed['proxy-groups'].map((group: any) => group?.name).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加代理名称
|
||||||
|
if (Array.isArray(parsed['proxies'])) {
|
||||||
|
groups.push(...parsed['proxies'].map((proxy: any) => proxy?.name).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预置出站 https://wiki.metacubex.one/config/proxies/built-in/
|
||||||
|
groups.push('DIRECT', 'REJECT', 'REJECT-DROP', 'PASS', 'COMPATIBLE')
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
setProxyGroups([...new Set(groups)])
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('解析配置文件内容失败', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getContent()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchTerm === '') {
|
||||||
|
setFilteredRules(rules)
|
||||||
|
} else {
|
||||||
|
const filtered = rules.filter(rule =>
|
||||||
|
rule.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
rule.payload.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(rule.proxy && rule.proxy.toLowerCase().includes(searchTerm.toLowerCase())) ||
|
||||||
|
(rule.additionalParams && rule.additionalParams.some(param => param.toLowerCase().includes(searchTerm.toLowerCase())))
|
||||||
|
)
|
||||||
|
setFilteredRules(filtered)
|
||||||
|
}
|
||||||
|
}, [searchTerm, rules])
|
||||||
|
|
||||||
|
const handleSave = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// 过滤掉已标记为删除的规则
|
||||||
|
const rulesToSave = rules.filter((_, index) => !deletedRules.has(index));
|
||||||
|
|
||||||
|
// 将规则转换回字符串格式
|
||||||
|
const ruleStrings = rulesToSave.map(rule => {
|
||||||
|
const parts = [rule.type]
|
||||||
|
if (rule.payload) parts.push(rule.payload)
|
||||||
|
if (rule.proxy) parts.push(rule.proxy)
|
||||||
|
if (rule.additionalParams && rule.additionalParams.length > 0) {
|
||||||
|
parts.push(...rule.additionalParams)
|
||||||
|
}
|
||||||
|
return parts.join(',')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 直接在原始内容中替换规则部分,保持原有格式
|
||||||
|
let updatedContent = profileContent
|
||||||
|
|
||||||
|
// 将内容按行分割,便于处理
|
||||||
|
const lines = profileContent.split('\n')
|
||||||
|
const newLines: string[] = []
|
||||||
|
let inRulesSection = false
|
||||||
|
let rulesSectionFound = false
|
||||||
|
let rulesIndent = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
const trimmedLine = line.trim()
|
||||||
|
|
||||||
|
// 检查是否是 rules: 开始行
|
||||||
|
if (trimmedLine === 'rules:') {
|
||||||
|
rulesSectionFound = true
|
||||||
|
inRulesSection = true
|
||||||
|
rulesIndent = line.match(/^\s*/)?.[0] || ''
|
||||||
|
newLines.push(line) // 保留 rules: 行
|
||||||
|
|
||||||
|
// 添加新的规则行
|
||||||
|
for (const rule of ruleStrings) {
|
||||||
|
newLines.push(`${rulesIndent} - ${rule}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过原有的 rules 内容
|
||||||
|
while (i + 1 < lines.length) {
|
||||||
|
const nextLine = lines[i + 1]
|
||||||
|
const nextTrimmed = nextLine.trim()
|
||||||
|
const nextIndent = nextLine.match(/^\s*/)?.[0] || ''
|
||||||
|
|
||||||
|
// 如果下一行不是空行且缩进大于 rules 缩进,说明还是 rules 的内容
|
||||||
|
if (nextTrimmed !== '' && nextIndent.length > rulesIndent.length) {
|
||||||
|
i++ // 跳过这一行
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果在 rules 部分中,跳过处理
|
||||||
|
if (inRulesSection && trimmedLine.startsWith('-')) {
|
||||||
|
// 检查是否还有 rules 行
|
||||||
|
const currentIndent = line.match(/^\s*/)?.[0] || ''
|
||||||
|
if (currentIndent.length > rulesIndent.length) {
|
||||||
|
continue // 跳过原有规则行
|
||||||
|
} else {
|
||||||
|
inRulesSection = false // rules 部分结束
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newLines.push(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到 rules 部分,添加到文件末尾
|
||||||
|
if (!rulesSectionFound) {
|
||||||
|
newLines.push('') // 空行
|
||||||
|
newLines.push('rules:')
|
||||||
|
for (const rule of ruleStrings) {
|
||||||
|
newLines.push(` - ${rule}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedContent = newLines.join('\n')
|
||||||
|
|
||||||
|
await setProfileStr(id, updatedContent)
|
||||||
|
onClose()
|
||||||
|
} catch (e) {
|
||||||
|
alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRuleTypeChange = (selected: string): void => {
|
||||||
|
const noResolveSupported = noResolveSupportTypes.includes(selected);
|
||||||
|
const srcSupported = srcSupportTypes.includes(selected);
|
||||||
|
|
||||||
|
let additionalParams = [...(newRule.additionalParams || [])];
|
||||||
|
if (!noResolveSupported) {
|
||||||
|
additionalParams = additionalParams.filter(param => param !== 'no-resolve');
|
||||||
|
}
|
||||||
|
if (!srcSupported) {
|
||||||
|
additionalParams = additionalParams.filter(param => param !== 'src');
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewRule({
|
||||||
|
...newRule,
|
||||||
|
type: selected,
|
||||||
|
additionalParams: additionalParams.length > 0 ? additionalParams : []
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdditionalParamChange = (param: string, checked: boolean): void => {
|
||||||
|
let newAdditionalParams = [...(newRule.additionalParams || [])];
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
if (!newAdditionalParams.includes(param)) {
|
||||||
|
newAdditionalParams.push(param);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newAdditionalParams = newAdditionalParams.filter(p => p !== param);
|
||||||
|
}
|
||||||
|
|
||||||
|
setNewRule({
|
||||||
|
...newRule,
|
||||||
|
additionalParams: newAdditionalParams
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => {
|
||||||
|
if (newRule.payload.trim() !== '' || newRule.type === 'MATCH') {
|
||||||
|
const newRuleItem = { ...newRule };
|
||||||
|
let updatedRules: RuleItem[];
|
||||||
|
|
||||||
|
if (position === 'prepend') {
|
||||||
|
updatedRules = [newRuleItem, ...rules];
|
||||||
|
} else {
|
||||||
|
updatedRules = [...rules, newRuleItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
setRules(updatedRules)
|
||||||
|
setFilteredRules(updatedRules)
|
||||||
|
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveRule = (index: number): void => {
|
||||||
|
setDeletedRules(prev => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
if (newSet.has(index)) {
|
||||||
|
newSet.delete(index) // 如果已经标记为删除,则取消标记
|
||||||
|
} else {
|
||||||
|
newSet.add(index) // 标记为删除
|
||||||
|
}
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveRuleUp = (index: number): void => {
|
||||||
|
if (index <= 0) return
|
||||||
|
const updatedRules = [...rules]
|
||||||
|
const temp = updatedRules[index]
|
||||||
|
updatedRules[index] = updatedRules[index - 1]
|
||||||
|
updatedRules[index - 1] = temp
|
||||||
|
setRules(updatedRules)
|
||||||
|
setFilteredRules(updatedRules)
|
||||||
|
setDeletedRules(prev => {
|
||||||
|
const newSet = new Set<number>()
|
||||||
|
prev.forEach(idx => {
|
||||||
|
if (idx === index) {
|
||||||
|
newSet.add(index - 1)
|
||||||
|
} else if (idx === index - 1) {
|
||||||
|
newSet.add(index)
|
||||||
|
} else {
|
||||||
|
newSet.add(idx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMoveRuleDown = (index: number): void => {
|
||||||
|
if (index >= rules.length - 1) return
|
||||||
|
const updatedRules = [...rules]
|
||||||
|
const temp = updatedRules[index]
|
||||||
|
updatedRules[index] = updatedRules[index + 1]
|
||||||
|
updatedRules[index + 1] = temp
|
||||||
|
setRules(updatedRules)
|
||||||
|
setFilteredRules(updatedRules)
|
||||||
|
setDeletedRules(prev => {
|
||||||
|
const newSet = new Set<number>()
|
||||||
|
prev.forEach(idx => {
|
||||||
|
if (idx === index) {
|
||||||
|
newSet.add(index + 1)
|
||||||
|
} else if (idx === index + 1) {
|
||||||
|
newSet.add(index)
|
||||||
|
} else {
|
||||||
|
newSet.add(idx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
backdrop="blur"
|
||||||
|
classNames={{ backdrop: 'top-[48px]' }}
|
||||||
|
size="5xl"
|
||||||
|
hideCloseButton
|
||||||
|
isOpen={true}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
scrollBehavior="inside"
|
||||||
|
>
|
||||||
|
<ModalContent className="h-full w-[calc(100%-100px)]">
|
||||||
|
<ModalHeader className="flex pb-0 app-drag">
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="flex items-center">{t('profiles.editRules.title')}</div>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalBody className="h-full">
|
||||||
|
<div className="flex gap-4 h-full">
|
||||||
|
{/* 左侧面板 - 规则表单 */}
|
||||||
|
<div className="w-1/3 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Select
|
||||||
|
label={t('profiles.editRules.ruleType')}
|
||||||
|
selectedKeys={[newRule.type]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const selected = Array.from(keys)[0] as string
|
||||||
|
handleRuleTypeChange(selected)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ruleTypes.map((type) => (
|
||||||
|
<SelectItem key={type}>
|
||||||
|
{type}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label={t('profiles.editRules.payload')}
|
||||||
|
placeholder={t('profiles.editRules.payloadPlaceholder')}
|
||||||
|
value={newRule.payload}
|
||||||
|
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
|
||||||
|
isDisabled={newRule.type === 'MATCH'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
label={t('profiles.editRules.proxy')}
|
||||||
|
placeholder={t('profiles.editRules.proxyPlaceholder')}
|
||||||
|
selectedKey={newRule.proxy}
|
||||||
|
onSelectionChange={(key) => setNewRule({ ...newRule, proxy: key as string })}
|
||||||
|
inputValue={newRule.proxy}
|
||||||
|
onInputChange={(value) => setNewRule({ ...newRule, proxy: value })}
|
||||||
|
>
|
||||||
|
{proxyGroups.map((group) => (
|
||||||
|
<AutocompleteItem key={group} textValue={group}>
|
||||||
|
{group}
|
||||||
|
</AutocompleteItem>
|
||||||
|
))}
|
||||||
|
</Autocomplete>
|
||||||
|
|
||||||
|
{/* 附加参数 */}
|
||||||
|
{(noResolveSupportTypes.includes(newRule.type) || srcSupportTypes.includes(newRule.type)) && (
|
||||||
|
<>
|
||||||
|
<Divider className="my-2" />
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{noResolveSupportTypes.includes(newRule.type) && (
|
||||||
|
<Checkbox
|
||||||
|
isSelected={newRule.additionalParams?.includes('no-resolve') || false}
|
||||||
|
onValueChange={(checked) => handleAdditionalParamChange('no-resolve', checked)}
|
||||||
|
>
|
||||||
|
{t('profiles.editRules.noResolve')}
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
{srcSupportTypes.includes(newRule.type) && (
|
||||||
|
<Checkbox
|
||||||
|
isSelected={newRule.additionalParams?.includes('src') || false}
|
||||||
|
onValueChange={(checked) => handleAdditionalParamChange('src', checked)}
|
||||||
|
>
|
||||||
|
{t('profiles.editRules.src')}
|
||||||
|
</Checkbox>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={() => handleAddRule('prepend')}
|
||||||
|
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type}
|
||||||
|
startContent={<MdVerticalAlignTop className="text-lg" />}
|
||||||
|
>
|
||||||
|
{t('profiles.editRules.addRulePrepend')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
variant="bordered"
|
||||||
|
onPress={() => handleAddRule('append')}
|
||||||
|
isDisabled={!(newRule.payload.trim() || newRule.type === 'MATCH') || !newRule.type}
|
||||||
|
startContent={<MdVerticalAlignBottom className="text-lg" />}
|
||||||
|
>
|
||||||
|
{t('profiles.editRules.addRuleAppend')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 border-t border-divider pt-4">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">{t('profiles.editRules.instructions')}</h3>
|
||||||
|
<div className="text-sm text-foreground-500">
|
||||||
|
<p className="mb-2">{t('profiles.editRules.instructions1')}</p>
|
||||||
|
<p className="mb-2">{t('profiles.editRules.instructions2')}</p>
|
||||||
|
<p>{t('profiles.editRules.instructions3')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧面板 - 规则列表 */}
|
||||||
|
<div className="w-2/3 border-l border-divider pl-4 flex flex-col">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h3 className="text-lg font-semibold">{t('profiles.editRules.currentRules')}</h3>
|
||||||
|
<Input
|
||||||
|
size="sm"
|
||||||
|
placeholder={t('profiles.editRules.searchPlaceholder')}
|
||||||
|
className="w-1/3"
|
||||||
|
value={searchTerm}
|
||||||
|
onValueChange={setSearchTerm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2 max-h-[calc(100vh-200px)] overflow-y-auto flex-1">
|
||||||
|
{filteredRules.length === 0 ? (
|
||||||
|
<div className="text-center text-foreground-500 py-4">
|
||||||
|
{rules.length === 0
|
||||||
|
? t('profiles.editRules.noRules')
|
||||||
|
: searchTerm
|
||||||
|
? t('profiles.editRules.noMatchingRules')
|
||||||
|
: t('profiles.editRules.noRules')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredRules.map((rule) => {
|
||||||
|
const originalIndex = rules.indexOf(rule);
|
||||||
|
return (
|
||||||
|
<div key={originalIndex} className={`flex items-center gap-2 p-2 rounded-lg ${deletedRules.has(originalIndex) ? 'bg-danger-50 opacity-70' : 'bg-content2'}`}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Chip size="sm" variant="flat">
|
||||||
|
{rule.type}
|
||||||
|
</Chip>
|
||||||
|
{/* 显示附加参数 */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{rule.additionalParams && rule.additionalParams.length > 0 && (
|
||||||
|
rule.additionalParams.map((param, idx) => (
|
||||||
|
<Chip key={idx} size="sm" variant="flat" color="secondary">{param}</Chip>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">
|
||||||
|
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
|
||||||
|
</div>
|
||||||
|
{rule.proxy && rule.type !== 'MATCH' && (
|
||||||
|
<div className="text-sm text-foreground-500 truncate">{rule.proxy}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleMoveRuleUp(originalIndex)}
|
||||||
|
isIconOnly
|
||||||
|
isDisabled={originalIndex === 0 || deletedRules.has(originalIndex)}
|
||||||
|
>
|
||||||
|
<IoMdArrowUp className="text-lg" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleMoveRuleDown(originalIndex)}
|
||||||
|
isIconOnly
|
||||||
|
isDisabled={originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
|
||||||
|
>
|
||||||
|
<IoMdArrowDown className="text-lg" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color={deletedRules.has(originalIndex) ? "success" : "danger"}
|
||||||
|
variant="light"
|
||||||
|
onPress={() => handleRemoveRule(originalIndex)}
|
||||||
|
isIconOnly
|
||||||
|
>
|
||||||
|
{deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter className="pt-0">
|
||||||
|
<Button size="sm" variant="light" onPress={() => {
|
||||||
|
setDeletedRules(new Set()) // 清除删除状态
|
||||||
|
onClose()
|
||||||
|
}}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
onPress={handleSave}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EditRulesModal
|
||||||
@ -17,6 +17,7 @@ import dayjs from '@renderer/utils/dayjs'
|
|||||||
import React, { Key, useMemo, useState } from 'react'
|
import React, { Key, useMemo, useState } from 'react'
|
||||||
import EditFileModal from './edit-file-modal'
|
import EditFileModal from './edit-file-modal'
|
||||||
import EditInfoModal from './edit-info-modal'
|
import EditInfoModal from './edit-info-modal'
|
||||||
|
import EditRulesModal from './edit-rules-modal'
|
||||||
import { useSortable } from '@dnd-kit/sortable'
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { openFile } from '@renderer/utils/ipc'
|
import { openFile } from '@renderer/utils/ipc'
|
||||||
@ -59,6 +60,7 @@ const ProfileItem: React.FC<Props> = (props) => {
|
|||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const [openInfoEditor, setOpenInfoEditor] = useState(false)
|
const [openInfoEditor, setOpenInfoEditor] = useState(false)
|
||||||
const [openFileEditor, setOpenFileEditor] = useState(false)
|
const [openFileEditor, setOpenFileEditor] = useState(false)
|
||||||
|
const [openRulesEditor, setOpenRulesEditor] = useState(false)
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
@ -90,6 +92,13 @@ const ProfileItem: React.FC<Props> = (props) => {
|
|||||||
color: 'default',
|
color: 'default',
|
||||||
className: ''
|
className: ''
|
||||||
} as MenuItem,
|
} as MenuItem,
|
||||||
|
{
|
||||||
|
key: 'edit-rules',
|
||||||
|
label: t('profiles.editRules.title'),
|
||||||
|
showDivider: false,
|
||||||
|
color: 'default',
|
||||||
|
className: ''
|
||||||
|
} as MenuItem,
|
||||||
{
|
{
|
||||||
key: 'open-file',
|
key: 'open-file',
|
||||||
label: t('profiles.openFile'),
|
label: t('profiles.openFile'),
|
||||||
@ -127,6 +136,10 @@ const ProfileItem: React.FC<Props> = (props) => {
|
|||||||
setOpenFileEditor(true)
|
setOpenFileEditor(true)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
case 'edit-rules': {
|
||||||
|
setOpenRulesEditor(true)
|
||||||
|
break
|
||||||
|
}
|
||||||
case 'open-file': {
|
case 'open-file': {
|
||||||
openFile('profile', info.id)
|
openFile('profile', info.id)
|
||||||
break
|
break
|
||||||
@ -201,6 +214,7 @@ const ProfileItem: React.FC<Props> = (props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{openFileEditor && <EditFileModal id={info.id} onClose={() => setOpenFileEditor(false)} />}
|
{openFileEditor && <EditFileModal id={info.id} onClose={() => setOpenFileEditor(false)} />}
|
||||||
|
{openRulesEditor && <EditRulesModal id={info.id} onClose={() => setOpenRulesEditor(false)} />}
|
||||||
{openInfoEditor && (
|
{openInfoEditor && (
|
||||||
<EditInfoModal
|
<EditInfoModal
|
||||||
item={info}
|
item={info}
|
||||||
|
|||||||
@ -434,6 +434,26 @@
|
|||||||
"profiles.editFile.notice": "Note: Changes made here will be reset after profile update. For custom configurations, please use",
|
"profiles.editFile.notice": "Note: Changes made here will be reset after profile update. For custom configurations, please use",
|
||||||
"profiles.editFile.override": "Override",
|
"profiles.editFile.override": "Override",
|
||||||
"profiles.editFile.feature": "feature",
|
"profiles.editFile.feature": "feature",
|
||||||
|
"profiles.editRules.title": "Edit Rules",
|
||||||
|
"profiles.editRules.ruleType": "Rule Type",
|
||||||
|
"profiles.editRules.payload": "Payload",
|
||||||
|
"profiles.editRules.payloadPlaceholder": "Enter payload",
|
||||||
|
"profiles.editRules.proxy": "Proxy",
|
||||||
|
"profiles.editRules.proxyPlaceholder": "Select or enter proxy",
|
||||||
|
"profiles.editRules.addRule": "Add Rule",
|
||||||
|
"profiles.editRules.addRulePrepend": "Add Rule to Top",
|
||||||
|
"profiles.editRules.addRuleAppend": "Add Rule to Bottom",
|
||||||
|
"profiles.editRules.instructions": "Instructions",
|
||||||
|
"profiles.editRules.instructions1": "1. Select rule type from dropdown menu",
|
||||||
|
"profiles.editRules.instructions2": "2. Enter payload",
|
||||||
|
"profiles.editRules.instructions3": "3. Select proxy and click Add Rule",
|
||||||
|
"profiles.editRules.currentRules": "Current Rules",
|
||||||
|
"profiles.editRules.searchPlaceholder": "Search rules...",
|
||||||
|
"profiles.editRules.noRules": "No rules",
|
||||||
|
"profiles.editRules.noMatchingRules": "No matching rules",
|
||||||
|
"profiles.editRules.saveError": "Error saving rules",
|
||||||
|
"profiles.editRules.noResolve": "Skip DNS Resolution (no-resolve)",
|
||||||
|
"profiles.editRules.src": "Match Source IP (src)",
|
||||||
"profiles.openFile": "Open File",
|
"profiles.openFile": "Open File",
|
||||||
"profiles.home": "Home",
|
"profiles.home": "Home",
|
||||||
"profiles.traffic.usage": "{{used}}/{{total}}",
|
"profiles.traffic.usage": "{{used}}/{{total}}",
|
||||||
|
|||||||
@ -414,6 +414,26 @@
|
|||||||
"profiles.editFile.notice": "توجه: تغییرات اعمال شده در اینجا پس از بهروزرسانی پروفایل بازنشانی میشوند. برای پیکربندیهای سفارشی، لطفا از",
|
"profiles.editFile.notice": "توجه: تغییرات اعمال شده در اینجا پس از بهروزرسانی پروفایل بازنشانی میشوند. برای پیکربندیهای سفارشی، لطفا از",
|
||||||
"profiles.editFile.override": "جایگزینی",
|
"profiles.editFile.override": "جایگزینی",
|
||||||
"profiles.editFile.feature": "ویژگی",
|
"profiles.editFile.feature": "ویژگی",
|
||||||
|
"profiles.editRules.title": "ویرایش قوانین",
|
||||||
|
"profiles.editRules.ruleType": "نوع قانون",
|
||||||
|
"profiles.editRules.payload": "محتوا",
|
||||||
|
"profiles.editRules.payloadPlaceholder": "محتوا را وارد کنید",
|
||||||
|
"profiles.editRules.proxy": "پروکسی",
|
||||||
|
"profiles.editRules.proxyPlaceholder": "پروکسی را انتخاب یا وارد کنید",
|
||||||
|
"profiles.editRules.addRule": "افزودن قانون",
|
||||||
|
"profiles.editRules.addRulePrepend": "افزودن قانون به ابتدا",
|
||||||
|
"profiles.editRules.addRuleAppend": "افزودن قانون به انتها",
|
||||||
|
"profiles.editRules.instructions": "دستورالعملها",
|
||||||
|
"profiles.editRules.instructions1": "۱. نوع قانون را از منوی کشویی انتخاب کنید",
|
||||||
|
"profiles.editRules.instructions2": "۲. محتوا را وارد کنید",
|
||||||
|
"profiles.editRules.instructions3": "۳. پروکسی را انتخاب کرده و روی افزودن قانون کلیک کنید",
|
||||||
|
"profiles.editRules.currentRules": "قوانین فعلی",
|
||||||
|
"profiles.editRules.searchPlaceholder": "جستجوی قوانین...",
|
||||||
|
"profiles.editRules.noRules": "بدون قانون",
|
||||||
|
"profiles.editRules.noMatchingRules": "قانونی یافت نشد",
|
||||||
|
"profiles.editRules.saveError": "خطا در ذخیره قوانین",
|
||||||
|
"profiles.editRules.noResolve": "رد کردن تحلیل DNS (no-resolve)",
|
||||||
|
"profiles.editRules.src": "مطابقت با IP منبع (src)",
|
||||||
"profiles.openFile": "باز کردن فایل",
|
"profiles.openFile": "باز کردن فایل",
|
||||||
"profiles.home": "خانه",
|
"profiles.home": "خانه",
|
||||||
"profiles.notification.importSuccess": "اشتراک با موفقیت وارد شد",
|
"profiles.notification.importSuccess": "اشتراک با موفقیت وارد شد",
|
||||||
|
|||||||
@ -414,6 +414,26 @@
|
|||||||
"profiles.editFile.notice": "Примечание: Изменения, сделанные здесь, будут сброшены после обновления профиля. Для пользовательских настроек используйте",
|
"profiles.editFile.notice": "Примечание: Изменения, сделанные здесь, будут сброшены после обновления профиля. Для пользовательских настроек используйте",
|
||||||
"profiles.editFile.override": "Переопределение",
|
"profiles.editFile.override": "Переопределение",
|
||||||
"profiles.editFile.feature": "функцию",
|
"profiles.editFile.feature": "функцию",
|
||||||
|
"profiles.editRules.title": "Редактировать правила",
|
||||||
|
"profiles.editRules.ruleType": "Тип правила",
|
||||||
|
"profiles.editRules.payload": "Полезная нагрузка",
|
||||||
|
"profiles.editRules.payloadPlaceholder": "Введите полезную нагрузку",
|
||||||
|
"profiles.editRules.proxy": "Прокси",
|
||||||
|
"profiles.editRules.proxyPlaceholder": "Выберите или введите прокси",
|
||||||
|
"profiles.editRules.addRule": "Добавить правило",
|
||||||
|
"profiles.editRules.addRulePrepend": "Добавить правило в начало",
|
||||||
|
"profiles.editRules.addRuleAppend": "Добавить правило в конец",
|
||||||
|
"profiles.editRules.instructions": "Инструкции",
|
||||||
|
"profiles.editRules.instructions1": "1. Выберите тип правила из выпадающего меню",
|
||||||
|
"profiles.editRules.instructions2": "2. Введите полезную нагрузку",
|
||||||
|
"profiles.editRules.instructions3": "3. Выберите прокси и нажмите Добавить правило",
|
||||||
|
"profiles.editRules.currentRules": "Текущие правила",
|
||||||
|
"profiles.editRules.searchPlaceholder": "Поиск правил...",
|
||||||
|
"profiles.editRules.noRules": "Нет правил",
|
||||||
|
"profiles.editRules.noMatchingRules": "Нет совпадающих правил",
|
||||||
|
"profiles.editRules.saveError": "Ошибка сохранения правил",
|
||||||
|
"profiles.editRules.noResolve": "Пропустить DNS-резолвинг (no-resolve)",
|
||||||
|
"profiles.editRules.src": "Сопоставить исходный IP (src)",
|
||||||
"profiles.openFile": "Открыть файл",
|
"profiles.openFile": "Открыть файл",
|
||||||
"profiles.home": "Главная",
|
"profiles.home": "Главная",
|
||||||
"profiles.notification.importSuccess": "Подписка успешно импортирована",
|
"profiles.notification.importSuccess": "Подписка успешно импортирована",
|
||||||
|
|||||||
@ -439,6 +439,26 @@
|
|||||||
"profiles.editFile.notice": "注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用",
|
"profiles.editFile.notice": "注意:此处编辑配置更新订阅后会还原,如需要自定义配置请使用",
|
||||||
"profiles.editFile.override": "覆写",
|
"profiles.editFile.override": "覆写",
|
||||||
"profiles.editFile.feature": "功能",
|
"profiles.editFile.feature": "功能",
|
||||||
|
"profiles.editRules.title": "编辑规则",
|
||||||
|
"profiles.editRules.ruleType": "规则类型",
|
||||||
|
"profiles.editRules.payload": "匹配内容",
|
||||||
|
"profiles.editRules.payloadPlaceholder": "请输入匹配内容",
|
||||||
|
"profiles.editRules.proxy": "代理节点",
|
||||||
|
"profiles.editRules.proxyPlaceholder": "请选择或输入代理节点",
|
||||||
|
"profiles.editRules.addRule": "添加规则",
|
||||||
|
"profiles.editRules.addRulePrepend": "添加前置规则",
|
||||||
|
"profiles.editRules.addRuleAppend": "添加后置规则",
|
||||||
|
"profiles.editRules.instructions": "使用说明",
|
||||||
|
"profiles.editRules.instructions1": "1. 在下拉菜单中选择规则类型",
|
||||||
|
"profiles.editRules.instructions2": "2. 输入匹配内容",
|
||||||
|
"profiles.editRules.instructions3": "3. 选择指定代理,然后点击添加规则",
|
||||||
|
"profiles.editRules.currentRules": "当前规则",
|
||||||
|
"profiles.editRules.searchPlaceholder": "搜索规则...",
|
||||||
|
"profiles.editRules.noRules": "暂无规则",
|
||||||
|
"profiles.editRules.noMatchingRules": "没有匹配的规则",
|
||||||
|
"profiles.editRules.saveError": "保存规则时出错",
|
||||||
|
"profiles.editRules.noResolve": "跳过DNS解析 (no-resolve)",
|
||||||
|
"profiles.editRules.src": "匹配源IP (src)",
|
||||||
"profiles.openFile": "打开文件",
|
"profiles.openFile": "打开文件",
|
||||||
"profiles.home": "主页",
|
"profiles.home": "主页",
|
||||||
"profiles.notification.importSuccess": "订阅导入成功",
|
"profiles.notification.importSuccess": "订阅导入成功",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user