Refactor rule (#1326)

* refactor: enhance prepend/append deletion in rule editor

* fix: Reject empty payload for non-MATCH rules

* More validator

* feat(backup): add rules directory support to local and WebDAV backups
This commit is contained in:
Memory 2025-11-01 18:19:07 +08:00 committed by GitHub
parent 0e58f6f314
commit 4af5cae356
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 596 additions and 156 deletions

View File

@ -12,10 +12,11 @@ import {
mihomoProfileWorkDir,
mihomoWorkConfigPath,
mihomoWorkDir,
overridePath
overridePath,
rulePath
} from '../utils/dirs'
import { parse, stringify } from '../utils/yaml'
import { copyFile, mkdir, writeFile } from 'fs/promises'
import { copyFile, mkdir, writeFile, readFile } from 'fs/promises'
import { deepMerge } from '../utils/merge'
import vm from 'vm'
import { existsSync, writeFileSync } from 'fs'
@ -24,11 +25,41 @@ import path from 'path'
let runtimeConfigStr: string
let runtimeConfig: IMihomoConfig
// 辅助函数:处理带偏移量的规则
function processRulesWithOffset(ruleStrings: string[], currentRules: string[], isAppend = false) {
const normalRules: string[] = []
let rules = [...currentRules]
ruleStrings.forEach(ruleStr => {
const parts = ruleStr.split(',')
const firstPartIsNumber = !isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3
if (firstPartIsNumber) {
const offset = parseInt(parts[0])
const rule = parts.slice(1).join(',')
if (isAppend) {
// 后置规则的插入位置计算
const insertPosition = Math.max(0, rules.length - Math.min(offset, rules.length))
rules.splice(insertPosition, 0, rule)
} else {
// 前置规则的插入位置计算
const insertPosition = Math.min(offset, rules.length)
rules.splice(insertPosition, 0, rule)
}
} else {
normalRules.push(ruleStr)
}
})
return { normalRules, insertRules: rules }
}
export async function generateProfile(): Promise<void> {
// 读取最新的配置
const { current } = await getProfileConfig(true)
const { diffWorkDir = false, controlDns = true, controlSniff = true, useNameserverPolicy } = await getAppConfig()
const currentProfile = await overrideProfile(current, await getProfile(current))
let currentProfile = await overrideProfile(current, await getProfile(current))
let controledMihomoConfig = await getControledMihomoConfig()
// 根据开关状态过滤控制配置
@ -44,6 +75,49 @@ export async function generateProfile(): Promise<void> {
delete controledMihomoConfig?.dns?.['nameserver-policy']
}
// 应用规则文件
try {
const ruleFilePath = rulePath(current || 'default')
if (existsSync(ruleFilePath)) {
const ruleFileContent = await readFile(ruleFilePath, 'utf-8')
const ruleData = parse(ruleFileContent) as { prepend?: string[], append?: string[], delete?: string[] } | null
if (ruleData && typeof ruleData === 'object') {
// 确保 rules 数组存在
if (!currentProfile.rules) {
currentProfile.rules = [] as unknown as []
}
let rules = [...currentProfile.rules] as unknown as string[]
// 处理前置规则
if (ruleData.prepend?.length) {
const { normalRules: prependRules, insertRules } = processRulesWithOffset(ruleData.prepend, rules)
rules = [...prependRules, ...insertRules]
}
// 处理后置规则
if (ruleData.append?.length) {
const { normalRules: appendRules, insertRules } = processRulesWithOffset(ruleData.append, rules, true)
rules = [...insertRules, ...appendRules]
}
// 处理删除规则
if (ruleData.delete?.length) {
const deleteSet = new Set(ruleData.delete)
rules = rules.filter(rule => {
const ruleStr = Array.isArray(rule) ? rule.join(',') : rule
return !deleteSet.has(ruleStr)
})
}
currentProfile.rules = rules as unknown as []
}
}
} catch (error) {
console.error('读取或应用规则文件时出错:', error)
}
const profile = deepMerge(currentProfile, controledMihomoConfig)
// 确保可以拿到基础日志信息
// 使用 debug 可以调试内核相关问题 `debug/pprof`

View File

@ -9,6 +9,7 @@ import {
overrideDir,
profileConfigPath,
profilesDir,
rulesDir,
subStoreDir,
themesDir
} from '../utils/dirs'
@ -38,6 +39,7 @@ export async function webdavBackup(): Promise<boolean> {
zip.addLocalFolder(themesDir(), 'themes')
zip.addLocalFolder(profilesDir(), 'profiles')
zip.addLocalFolder(overrideDir(), 'override')
zip.addLocalFolder(rulesDir(), 'rules')
zip.addLocalFolder(subStoreDir(), 'substore')
const date = new Date()
const zipFileName = `${process.platform}_${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`
@ -229,6 +231,9 @@ export async function exportLocalBackup(): Promise<boolean> {
if (existsSync(subStoreDir())) {
zip.addLocalFolder(subStoreDir(), 'substore')
}
if (existsSync(rulesDir())) {
zip.addLocalFolder(rulesDir(), 'rules')
}
const date = new Date()
const zipFileName = `clash-party-backup-${dayjs(date).format('YYYY-MM-DD_HH-mm-ss')}.zip`

View File

@ -158,3 +158,11 @@ export function coreLogPath(): string {
const name = `core-${year}-${month}-${day}`
return path.join(logDir(), `${name}.log`)
}
export function rulesDir(): string {
return path.join(dataDir(), 'rules')
}
export function rulePath(id: string): string {
return path.join(rulesDir(), `${id}.yaml`)
}

View File

@ -11,6 +11,7 @@ import {
profilePath,
profilesDir,
resourcesFilesDir,
rulesDir,
subStoreDir,
themesDir
} from './dirs'
@ -106,6 +107,7 @@ async function initDirs(): Promise<void> {
themesDir(),
profilesDir(),
overrideDir(),
rulesDir(),
mihomoWorkDir(),
logDir(),
mihomoTestDir(),

View File

@ -99,7 +99,7 @@ import {
writeTheme
} from '../resolve/theme'
import { subStoreCollections, subStoreSubs } from '../core/subStoreApi'
import { logDir } from './dirs'
import { logDir, rulePath } from './dirs'
import path from 'path'
import v8 from 'v8'
import { getGistUrl } from '../resolve/gistApi'
@ -148,6 +148,18 @@ export async function clearMihomoVersionCache(): Promise<void> {
clearVersionCache('MetaCubeX', 'mihomo')
}
export async function getRuleStr(id: string): Promise<string> {
const { readFile } = await import('fs/promises')
const filePath = rulePath(id)
return await readFile(filePath, 'utf-8')
}
export async function setRuleStr(id: string, str: string): Promise<void> {
const { writeFile } = await import('fs/promises')
const filePath = rulePath(id)
await writeFile(filePath, str, 'utf-8')
}
export function registerIpcMainHandlers(): void {
ipcMain.handle('mihomoVersion', ipcErrorWrapper(mihomoVersion))
ipcMain.handle('mihomoCloseConnection', (_e, id) => ipcErrorWrapper(mihomoCloseConnection)(id))
@ -348,4 +360,8 @@ export function registerIpcMainHandlers(): void {
// 注册清除版本缓存的IPC处理程序
ipcMain.handle('clearMihomoVersionCache', () => ipcErrorWrapper(clearMihomoVersionCache)())
// 规则相关IPC处理程序
ipcMain.handle('getRuleStr', (_e, id) => ipcErrorWrapper(getRuleStr)(id))
ipcMain.handle('setRuleStr', (_e, id, str) => ipcErrorWrapper(setRuleStr)(id, str))
}

View File

@ -15,7 +15,7 @@ import {
Divider
} from '@heroui/react'
import React, { useEffect, useState } from 'react'
import { getProfileStr, setProfileStr } from '@renderer/utils/ipc'
import { getProfileStr, setRuleStr, getRuleStr } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next'
import yaml from 'js-yaml'
import { IoMdTrash, IoMdArrowUp, IoMdArrowDown, IoMdUndo } from 'react-icons/io'
@ -32,8 +32,37 @@ interface RuleItem {
payload: string
proxy: string
additionalParams?: string[]
offset?: number
}
const domainValidator = (value: string): boolean => {
if (value.length > 253 || value.length < 2) return false;
return new RegExp(
"^(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)\\.)+[a-zA-Z]{2,}$"
).test(value) ||
["localhost", "local", "localdomain"].includes(value.toLowerCase());
};
const domainSuffixValidator = (value: string): boolean => {
return new RegExp(
"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\\.[a-zA-Z]{2,}$"
).test(value);
};
const domainKeywordValidator = (value: string): boolean => {
return value.length > 0 && !value.includes(",") && !value.includes(" ");
};
const domainRegexValidator = (value: string): boolean => {
try {
new RegExp(value);
return true;
} catch {
return false;
}
};
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])$",
@ -64,18 +93,22 @@ const ruleDefinitionsMap = new Map<string, {
["DOMAIN", {
name: "DOMAIN",
example: "example.com",
validator: (value) => domainValidator(value)
}],
["DOMAIN-SUFFIX", {
name: "DOMAIN-SUFFIX",
example: "example.com",
validator: (value) => domainSuffixValidator(value)
}],
["DOMAIN-KEYWORD", {
name: "DOMAIN-KEYWORD",
example: "example",
validator: (value) => domainKeywordValidator(value)
}],
["DOMAIN-REGEX", {
name: "DOMAIN-REGEX",
example: "example.*",
validator: (value) => domainRegexValidator(value)
}],
["GEOSITE", {
name: "GEOSITE",
@ -241,7 +274,7 @@ const getRuleExample = (ruleType: string): string => {
};
const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: string, payload: string) => boolean): boolean => {
return (!(newRule.payload.trim() || newRule.type === 'MATCH')) || !newRule.type ||
return (!(newRule.payload.trim() || newRule.type === 'MATCH')) || !newRule.type || !newRule.proxy ||
(newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload));
};
@ -249,11 +282,13 @@ 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 [, 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 [deletedRules, setDeletedRules] = useState<Set<number>>(new Set())
const [prependRules, setPrependRules] = useState<Set<number>>(new Set())
const [appendRules, setAppendRules] = useState<Set<number>>(new Set())
const { t } = useTranslation()
const getContent = async (): Promise<void> => {
@ -262,12 +297,15 @@ const EditRulesModal: React.FC<Props> = (props) => {
try {
const parsed = yaml.load(content) as any
let initialRules: RuleItem[] = [];
if (parsed && parsed.rules && Array.isArray(parsed.rules)) {
const parsedRules = parsed.rules.map((rule: string) => {
initialRules = parsed.rules.map((rule: string) => {
const parts = rule.split(',')
if (parts[0] === 'MATCH') {
return {
type: 'MATCH',
payload: '',
proxy: parts[1]
}
} else {
@ -279,21 +317,18 @@ const EditRulesModal: React.FC<Props> = (props) => {
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))
}
@ -304,8 +339,116 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 去重
setProxyGroups([...new Set(groups)])
}
// 读取规则文件
try {
const ruleContent = await getRuleStr(id);
const ruleData = yaml.load(ruleContent) as { prepend?: string[], append?: string[], delete?: string[] };
if (ruleData) {
let allRules = [...initialRules];
const newPrependRules = new Set<number>();
const newAppendRules = new Set<number>();
const newDeletedRules = new Set<number>();
// 处理前置规则
if (ruleData.prepend && Array.isArray(ruleData.prepend)) {
const prependRules: RuleItem[] = [];
ruleData.prepend.forEach((ruleStr: string) => {
prependRules.push(parseRuleString(ruleStr));
});
// 插入前置规则
const { updatedRules, ruleIndices } = processRulesWithPositions(
prependRules,
allRules,
(rule, currentRules) => {
if (rule.offset !== undefined && rule.offset < currentRules.length) {
return rule.offset;
}
return 0;
}
);
allRules = updatedRules;
ruleIndices.forEach(index => newPrependRules.add(index));
}
// 处理后置规则
if (ruleData.append && Array.isArray(ruleData.append)) {
const appendRules: RuleItem[] = [];
ruleData.append.forEach((ruleStr: string) => {
appendRules.push(parseRuleString(ruleStr));
});
// 插入后置规则
const { updatedRules, ruleIndices } = processAppendRulesWithPositions(
appendRules,
allRules,
(rule, currentRules) => {
if (rule.offset !== undefined) {
return Math.max(0, currentRules.length - rule.offset);
}
return currentRules.length;
}
);
allRules = updatedRules;
// 标记后置规则
ruleIndices.forEach(index => newAppendRules.add(index));
}
// 处理删除规则
if (ruleData.delete && Array.isArray(ruleData.delete)) {
const deleteRules = ruleData.delete.map((ruleStr: string) => {
return parseRuleString(ruleStr);
});
// 匹配并标记删除规则
deleteRules.forEach(deleteRule => {
const matchedIndex = allRules.findIndex(rule =>
rule.type === deleteRule.type &&
rule.payload === deleteRule.payload &&
rule.proxy === deleteRule.proxy &&
JSON.stringify(rule.additionalParams || []) === JSON.stringify(deleteRule.additionalParams || [])
);
if (matchedIndex !== -1) {
newDeletedRules.add(matchedIndex);
}
});
}
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
// 设置规则列表
setRules(allRules);
setFilteredRules(allRules);
} else {
// 使用初始规则
setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记
setPrependRules(new Set());
setAppendRules(new Set());
setDeletedRules(new Set());
}
} catch (ruleError) {
// 规则文件读取失败
console.debug('规则文件读取失败:', ruleError);
setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记
setPrependRules(new Set());
setAppendRules(new Set());
setDeletedRules(new Set());
}
} catch (e) {
console.error('解析配置文件内容失败', e)
console.error('Failed to parse profile content', e)
}
}
@ -329,91 +472,42 @@ const EditRulesModal: React.FC<Props> = (props) => {
const handleSave = async (): Promise<void> => {
try {
// 过滤掉已标记为删除的规则
const rulesToSave = rules.filter((_, index) => !deletedRules.has(index));
// 保存规则到文件
const prependRuleStrings = Array.from(prependRules)
.filter(index => !deletedRules.has(index) && index < rules.length)
.map(index => convertRuleToString(rules[index]));
// 将规则转换回字符串格式
const ruleStrings = rulesToSave.map(rule => {
const parts = [rule.type]
if (rule.payload) parts.push(rule.payload)
if (rule.proxy) parts.push(rule.proxy)
const appendRuleStrings = Array.from(appendRules)
.filter(index => !deletedRules.has(index) && index < rules.length)
.map(index => convertRuleToString(rules[index]));
// 保存删除的规则
const deletedRuleStrings = Array.from(deletedRules)
.filter(index => index < rules.length && !prependRules.has(index) && !appendRules.has(index))
.map(index => {
const rule = rules[index];
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)
parts.push(...rule.additionalParams);
}
return parts.join(',')
})
return parts.join(',');
});
// 直接在原始内容中替换规则部分,保持原有格式
let updatedContent = profileContent
// 创建规则数据对象
const ruleData = {
prepend: prependRuleStrings,
append: appendRuleStrings,
delete: deletedRuleStrings
};
// 将内容按行分割,便于处理
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()
// 保存到 YAML 文件
const ruleYaml = yaml.dump(ruleData);
await setRuleStr(id, ruleYaml);
onClose();
} catch (e) {
alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e)))
alert(t('profiles.editRules.saveError') + ': ' + (e instanceof Error ? e.message : String(e)));
}
}
@ -464,73 +558,129 @@ const EditRulesModal: React.FC<Props> = (props) => {
let updatedRules: RuleItem[];
if (position === 'prepend') {
updatedRules = [newRuleItem, ...rules];
// 前置规则插入
const insertPosition = newRuleItem.offset !== undefined ?
Math.min(newRuleItem.offset, rules.length) : 0;
updatedRules = [...rules];
updatedRules.splice(insertPosition, 0, newRuleItem);
// 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, true);
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
} else {
updatedRules = [...rules, newRuleItem];
// 后置规则插入
const insertPosition = newRuleItem.offset !== undefined ?
Math.max(0, rules.length - newRuleItem.offset) :
rules.length;
updatedRules = [...rules];
updatedRules.splice(insertPosition, 0, newRuleItem);
// 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, false, true);
// 更新状态
setPrependRules(newPrependRules);
setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules);
}
setRules(updatedRules)
setFilteredRules(updatedRules)
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
// 更新规则列表
setRules(updatedRules);
setFilteredRules(updatedRules);
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] });
}
}
const handleRemoveRule = (index: number): void => {
setDeletedRules(prev => {
const newSet = new Set(prev)
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index) // 如果已经标记为删除,则取消标记
newSet.delete(index); // 如果已经标记为删除,则取消标记
} else {
newSet.add(index) // 标记为删除
newSet.add(index); // 标记为删除
}
return newSet
})
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)
if (index <= 0) return;
const updatedRules = [...rules];
const temp = updatedRules[index];
updatedRules[index] = updatedRules[index - 1];
updatedRules[index - 1] = temp;
// 更新前置规则偏移量
if (prependRules.has(index)) {
updatedRules[index - 1] = {
...updatedRules[index - 1],
offset: Math.max(0, (updatedRules[index - 1].offset || 0) - 1)
};
}
})
return newSet
})
// 更新后置规则偏移量
if (appendRules.has(index)) {
updatedRules[index - 1] = {
...updatedRules[index - 1],
offset: (updatedRules[index - 1].offset || 0) + 1
};
}
// 首先更新规则数组
setRules(updatedRules);
setFilteredRules(updatedRules);
// 更新删除规则索引
setDeletedRules(prev => updateRuleIndices(prev, index, index - 1));
// 更新前置规则索引
setPrependRules(prev => updateRuleIndices(prev, index, index - 1));
// 更新后置规则索引
setAppendRules(prev => updateRuleIndices(prev, index, index - 1));
}
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)
if (index >= rules.length - 1) return;
const updatedRules = [...rules];
const temp = updatedRules[index];
updatedRules[index] = updatedRules[index + 1];
updatedRules[index + 1] = temp;
// 更新前置规则偏移量
if (prependRules.has(index)) {
updatedRules[index + 1] = {
...updatedRules[index + 1],
offset: (updatedRules[index + 1].offset || 0) + 1
};
}
})
return newSet
})
// 更新后置规则偏移量
if (appendRules.has(index)) {
updatedRules[index + 1] = {
...updatedRules[index + 1],
offset: Math.max(0, (updatedRules[index + 1].offset || 0) - 1)
};
}
// 首先更新规则数组
setRules(updatedRules);
setFilteredRules(updatedRules);
// 更新删除规则索引
setDeletedRules(prev => updateRuleIndices(prev, index, index + 1));
// 更新前置规则索引
setPrependRules(prev => updateRuleIndices(prev, index, index + 1));
// 更新后置规则索引
setAppendRules(prev => updateRuleIndices(prev, index, index + 1));
}
const validateRulePayload = (ruleType: string, payload: string): boolean => {
@ -551,6 +701,174 @@ const EditRulesModal: React.FC<Props> = (props) => {
return rule?.validator;
};
// 解析规则字符串
const parseRuleString = (ruleStr: string): RuleItem => {
const parts = ruleStr.split(',');
const firstPartIsNumber = !isNaN(Number(parts[0])) && parts[0].trim() !== '' && parts.length >= 3;
let offset = 0;
let ruleParts = parts;
if (firstPartIsNumber) {
offset = parseInt(parts[0]);
ruleParts = parts.slice(1);
}
if (ruleParts[0] === 'MATCH') {
return {
type: 'MATCH',
payload: '',
proxy: ruleParts[1],
offset: offset > 0 ? offset : undefined
};
} else {
const additionalParams = ruleParts.slice(3).filter(param => param.trim() !== '') || [];
return {
type: ruleParts[0],
payload: ruleParts[1],
proxy: ruleParts[2],
additionalParams,
offset: offset > 0 ? offset : undefined
};
}
};
// 规则转字符串
const convertRuleToString = (rule: RuleItem): string => {
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);
}
// 添加偏移量
if (rule.offset !== undefined && rule.offset > 0) {
parts.unshift(rule.offset.toString());
}
return parts.join(',');
};
// 处理前置规则位置
const processRulesWithPositions = (rules: RuleItem[], allRules: RuleItem[], positionCalculator: (rule: RuleItem, currentRules: RuleItem[]) => number): { updatedRules: RuleItem[], ruleIndices: Set<number> } => {
const updatedRules = [...allRules];
const ruleIndices = new Set<number>();
// 按顺序处理规则
rules.forEach((rule) => {
const targetPosition = positionCalculator(rule, updatedRules);
const actualPosition = Math.min(targetPosition, updatedRules.length);
updatedRules.splice(actualPosition, 0, rule);
// 更新索引
const newRuleIndices = new Set<number>();
ruleIndices.forEach(idx => {
if (idx >= actualPosition) {
newRuleIndices.add(idx + 1);
} else {
newRuleIndices.add(idx);
}
});
// 添加当前规则索引
newRuleIndices.add(actualPosition);
// 更新索引集合
ruleIndices.clear();
newRuleIndices.forEach(idx => ruleIndices.add(idx));
});
return { updatedRules, ruleIndices };
};
// 处理后置规则位置
const processAppendRulesWithPositions = (rules: RuleItem[], allRules: RuleItem[], positionCalculator: (rule: RuleItem, currentRules: RuleItem[]) => number): { updatedRules: RuleItem[], ruleIndices: Set<number> } => {
const updatedRules = [...allRules];
const ruleIndices = new Set<number>();
// 按顺序处理规则
rules.forEach((rule) => {
const targetPosition = positionCalculator(rule, updatedRules);
const actualPosition = Math.min(targetPosition, updatedRules.length);
updatedRules.splice(actualPosition, 0, rule);
// 更新索引
const newRuleIndices = new Set<number>();
ruleIndices.forEach(idx => {
if (idx >= actualPosition) {
newRuleIndices.add(idx + 1);
} else {
newRuleIndices.add(idx);
}
});
// 添加当前规则索引
newRuleIndices.add(actualPosition);
// 更新索引集合
ruleIndices.clear();
newRuleIndices.forEach(idx => ruleIndices.add(idx));
});
return { updatedRules, ruleIndices };
};
// 更新规则索引
const updateRuleIndices = (prev: Set<number>, index1: number, index2: number): Set<number> => {
const newSet = new Set<number>();
prev.forEach(idx => {
if (idx === index1) {
newSet.add(index2);
} else if (idx === index2) {
newSet.add(index1);
} else {
newSet.add(idx);
}
});
return newSet;
};
// 计算插入位置的索引
const getUpdatedIndexForInsertion = (index: number, insertPosition: number): number => {
if (index >= insertPosition) {
return index + 1;
} else {
return index;
}
};
// 插入规则后更新所有索引
const updateAllRuleIndicesAfterInsertion = (prependRules: Set<number>, appendRules: Set<number>, deletedRules: Set<number>, insertPosition: number, isNewPrependRule: boolean = false, isNewAppendRule: boolean = false): { newPrependRules: Set<number>, newAppendRules: Set<number>, newDeletedRules: Set<number> } => {
const newPrependRules = new Set<number>();
const newAppendRules = new Set<number>();
const newDeletedRules = new Set<number>();
// 更新前置规则索引
prependRules.forEach(idx => {
newPrependRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 更新后置规则索引
appendRules.forEach(idx => {
newAppendRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 更新删除规则索引
deletedRules.forEach(idx => {
newDeletedRules.add(getUpdatedIndexForInsertion(idx, insertPosition));
});
// 标记新规则
if (isNewPrependRule) {
newPrependRules.add(insertPosition);
}
if (isNewAppendRule) {
newAppendRules.add(insertPosition);
}
return { newPrependRules, newAppendRules, newDeletedRules };
};
return (
<Modal
backdrop="blur"
@ -689,10 +1007,19 @@ const EditRulesModal: React.FC<Props> = (props) => {
: t('profiles.editRules.noRules')}
</div>
) : (
filteredRules.map((rule) => {
filteredRules.map((rule, index) => {
const originalIndex = rules.indexOf(rule);
let bgColorClass = 'bg-content2';
let textStyleClass = '';
if (deletedRules.has(originalIndex)) {
bgColorClass = 'bg-danger-50 opacity-70';
textStyleClass = 'line-through text-foreground-500';
} else if (prependRules.has(originalIndex) || appendRules.has(originalIndex)) {
bgColorClass = 'bg-success-50';
}
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 key={`${originalIndex}-${index}`} className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}>
<div className="flex flex-col">
<div className="flex items-center gap-1">
<Chip size="sm" variant="flat">
@ -709,40 +1036,40 @@ const EditRulesModal: React.FC<Props> = (props) => {
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
<div className={`font-medium truncate ${textStyleClass}`}>
{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 className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>{rule.proxy}</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => handleMoveRuleUp(originalIndex)}
onPress={() => originalIndex !== -1 && handleMoveRuleUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === 0 || deletedRules.has(originalIndex)}
isDisabled={originalIndex === -1 || originalIndex === 0 || deletedRules.has(originalIndex)}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => handleMoveRuleDown(originalIndex)}
onPress={() => originalIndex !== -1 && handleMoveRuleDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
isDisabled={originalIndex === -1 || originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={deletedRules.has(originalIndex) ? "success" : "danger"}
color={originalIndex !== -1 && deletedRules.has(originalIndex) ? "success" : "danger"}
variant="light"
onPress={() => handleRemoveRule(originalIndex)}
onPress={() => originalIndex !== -1 && handleRemoveRule(originalIndex)}
isIconOnly
>
{deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
{originalIndex !== -1 && deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
</Button>
</div>
</div>

View File

@ -549,6 +549,14 @@ export async function copyEnv(type: 'bash' | 'cmd' | 'powershell'): Promise<void
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('copyEnv', type))
}
export async function getRuleStr(id: string): Promise<string> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('getRuleStr', id))
}
export async function setRuleStr(id: string, str: string): Promise<void> {
return ipcErrorWrapper(await window.electron.ipcRenderer.invoke('setRuleStr', id, str))
}
async function alert<T>(msg: T): Promise<void> {
const msgStr = typeof msg === 'string' ? msg : JSON.stringify(msg)
return await window.electron.ipcRenderer.invoke('alert', msgStr)