diff --git a/src/renderer/src/components/profiles/edit-rules-modal.tsx b/src/renderer/src/components/profiles/edit-rules-modal.tsx new file mode 100644 index 0000000..0235d0d --- /dev/null +++ b/src/renderer/src/components/profiles/edit-rules-modal.tsx @@ -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) => { + const { id, onClose } = props + const [rules, setRules] = useState([]) + const [filteredRules, setFilteredRules] = useState([]) + const [profileContent, setProfileContent] = useState('') + const [newRule, setNewRule] = useState({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }) + const [searchTerm, setSearchTerm] = useState('') + const [proxyGroups, setProxyGroups] = useState([]) + const [deletedRules, setDeletedRules] = useState>(new Set()) // Store indices of deleted rules + const { t } = useTranslation() + + const getContent = async (): Promise => { + 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 => { + 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() + 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() + 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 ( + + + +
+
{t('profiles.editRules.title')}
+
+
+ +
+ {/* 左侧面板 - 规则表单 */} +
+
+ + + setNewRule({ ...newRule, payload: value })} + isDisabled={newRule.type === 'MATCH'} + /> + + setNewRule({ ...newRule, proxy: key as string })} + inputValue={newRule.proxy} + onInputChange={(value) => setNewRule({ ...newRule, proxy: value })} + > + {proxyGroups.map((group) => ( + + {group} + + ))} + + + {/* 附加参数 */} + {(noResolveSupportTypes.includes(newRule.type) || srcSupportTypes.includes(newRule.type)) && ( + <> + +
+ {noResolveSupportTypes.includes(newRule.type) && ( + handleAdditionalParamChange('no-resolve', checked)} + > + {t('profiles.editRules.noResolve')} + + )} + {srcSupportTypes.includes(newRule.type) && ( + handleAdditionalParamChange('src', checked)} + > + {t('profiles.editRules.src')} + + )} +
+ + )} + +
+ + +
+
+ +
+

{t('profiles.editRules.instructions')}

+
+

{t('profiles.editRules.instructions1')}

+

{t('profiles.editRules.instructions2')}

+

{t('profiles.editRules.instructions3')}

+
+
+
+ + {/* 右侧面板 - 规则列表 */} +
+
+

{t('profiles.editRules.currentRules')}

+ +
+
+ {filteredRules.length === 0 ? ( +
+ {rules.length === 0 + ? t('profiles.editRules.noRules') + : searchTerm + ? t('profiles.editRules.noMatchingRules') + : t('profiles.editRules.noRules')} +
+ ) : ( + filteredRules.map((rule) => { + const originalIndex = rules.indexOf(rule); + return ( +
+
+
+ + {rule.type} + + {/* 显示附加参数 */} +
+ {rule.additionalParams && rule.additionalParams.length > 0 && ( + rule.additionalParams.map((param, idx) => ( + {param} + )) + )} +
+
+
+
+
+ {rule.type === 'MATCH' ? rule.proxy : rule.payload} +
+ {rule.proxy && rule.type !== 'MATCH' && ( +
{rule.proxy}
+ )} +
+
+ + + +
+
+ ) + }) + )} +
+
+
+
+ + + + +
+
+ ) +} + +export default EditRulesModal \ No newline at end of file diff --git a/src/renderer/src/components/profiles/profile-item.tsx b/src/renderer/src/components/profiles/profile-item.tsx index 72f0de5..12b49c5 100644 --- a/src/renderer/src/components/profiles/profile-item.tsx +++ b/src/renderer/src/components/profiles/profile-item.tsx @@ -17,6 +17,7 @@ import dayjs from '@renderer/utils/dayjs' import React, { Key, useMemo, useState } from 'react' import EditFileModal from './edit-file-modal' import EditInfoModal from './edit-info-modal' +import EditRulesModal from './edit-rules-modal' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { openFile } from '@renderer/utils/ipc' @@ -59,6 +60,7 @@ const ProfileItem: React.FC = (props) => { const [updating, setUpdating] = useState(false) const [openInfoEditor, setOpenInfoEditor] = useState(false) const [openFileEditor, setOpenFileEditor] = useState(false) + const [openRulesEditor, setOpenRulesEditor] = useState(false) const [dropdownOpen, setDropdownOpen] = useState(false) const { attributes, @@ -90,6 +92,13 @@ const ProfileItem: React.FC = (props) => { color: 'default', className: '' } as MenuItem, + { + key: 'edit-rules', + label: t('profiles.editRules.title'), + showDivider: false, + color: 'default', + className: '' + } as MenuItem, { key: 'open-file', label: t('profiles.openFile'), @@ -127,6 +136,10 @@ const ProfileItem: React.FC = (props) => { setOpenFileEditor(true) break } + case 'edit-rules': { + setOpenRulesEditor(true) + break + } case 'open-file': { openFile('profile', info.id) break @@ -201,6 +214,7 @@ const ProfileItem: React.FC = (props) => { }} > {openFileEditor && setOpenFileEditor(false)} />} + {openRulesEditor && setOpenRulesEditor(false)} />} {openInfoEditor && (