perf: rule edit page

This commit is contained in:
ezequielnick 2025-11-15 21:01:59 +08:00
parent e1b8c9960a
commit f953dca228

View File

@ -12,9 +12,10 @@ import {
Autocomplete, Autocomplete,
AutocompleteItem, AutocompleteItem,
Checkbox, Checkbox,
Divider Divider,
Spinner
} from '@heroui/react' } from '@heroui/react'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useMemo, useCallback, startTransition, memo, useDeferredValue } from 'react'
import { getProfileStr, setRuleStr, getRuleStr } from '@renderer/utils/ipc' import { getProfileStr, setRuleStr, getRuleStr } from '@renderer/utils/ipc'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import yaml from 'js-yaml' import yaml from 'js-yaml'
@ -278,24 +279,152 @@ const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: st
(newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)); (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload));
}; };
// 避免整个列表重新渲染
interface RuleListItemProps {
rule: RuleItem;
originalIndex: number;
isDeleted: boolean;
isPrependOrAppend: boolean;
rulesLength: number;
onMoveUp: (index: number) => void;
onMoveDown: (index: number) => void;
onRemove: (index: number) => void;
}
const RuleListItem = memo<RuleListItemProps>(({
rule,
originalIndex,
isDeleted,
isPrependOrAppend,
rulesLength,
onMoveUp,
onMoveDown,
onRemove
}) => {
let bgColorClass = 'bg-content2';
let textStyleClass = '';
if (isDeleted) {
bgColorClass = 'bg-danger-50 opacity-70';
textStyleClass = 'line-through text-foreground-500';
} else if (isPrependOrAppend) {
bgColorClass = 'bg-success-50';
}
return (
<div 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">
{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 ${textStyleClass}`}>
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div>
{rule.proxy && rule.type !== 'MATCH' && (
<div className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>{rule.proxy}</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === 0 || isDeleted}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && onMoveDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === rulesLength - 1 || isDeleted}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={originalIndex !== -1 && isDeleted ? "success" : "danger"}
variant="light"
onPress={() => originalIndex !== -1 && onRemove(originalIndex)}
isIconOnly
>
{originalIndex !== -1 && isDeleted ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
</Button>
</div>
</div>
);
}, (prevProps, nextProps) => {
return (
prevProps.rule === nextProps.rule &&
prevProps.originalIndex === nextProps.originalIndex &&
prevProps.isDeleted === nextProps.isDeleted &&
prevProps.isPrependOrAppend === nextProps.isPrependOrAppend &&
prevProps.rulesLength === nextProps.rulesLength
);
});
const EditRulesModal: React.FC<Props> = (props) => { const EditRulesModal: React.FC<Props> = (props) => {
const { id, onClose } = props const { id, onClose } = props
const [rules, setRules] = useState<RuleItem[]>([]) const [rules, setRules] = useState<RuleItem[]>([])
const [filteredRules, setFilteredRules] = useState<RuleItem[]>([])
const [, setProfileContent] = useState('') const [, setProfileContent] = useState('')
const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }) const [newRule, setNewRule] = useState<RuleItem>({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] })
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [deferredSearchTerm, setDeferredSearchTerm] = useState('')
const [proxyGroups, setProxyGroups] = useState<string[]>([]) const [proxyGroups, setProxyGroups] = useState<string[]>([])
const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set()) const [deletedRules, setDeletedRules] = useState<Set<number>>(new Set())
const [prependRules, setPrependRules] = useState<Set<number>>(new Set()) const [prependRules, setPrependRules] = useState<Set<number>>(new Set())
const [appendRules, setAppendRules] = useState<Set<number>>(new Set()) const [appendRules, setAppendRules] = useState<Set<number>>(new Set())
const [isLoading, setIsLoading] = useState(true)
const { t } = useTranslation() const { t } = useTranslation()
const getContent = async (): Promise<void> => { const ruleIndexMap = useMemo(() => {
const content = await getProfileStr(id) const map = new Map<RuleItem, number>()
setProfileContent(content) rules.forEach((rule, index) => {
map.set(rule, index)
})
return map
}, [rules])
const filteredRules = useMemo(() => {
if (deferredSearchTerm === '') return rules
const lowerSearch = deferredSearchTerm.toLowerCase()
return rules.filter(rule =>
rule.type.toLowerCase().includes(lowerSearch) ||
rule.payload.toLowerCase().includes(lowerSearch) ||
(rule.proxy && rule.proxy.toLowerCase().includes(lowerSearch)) ||
(rule.additionalParams && rule.additionalParams.some(param => param.toLowerCase().includes(lowerSearch)))
)
}, [deferredSearchTerm, rules])
useEffect(() => {
startTransition(() => {
setDeferredSearchTerm(searchTerm)
})
}, [searchTerm])
const deferredFilteredRules = useDeferredValue(filteredRules)
const getContent = async (): Promise<void> => {
setIsLoading(true)
try { try {
const content = await getProfileStr(id)
setProfileContent(content)
const parsed = yaml.load(content) as any const parsed = yaml.load(content) as any
let initialRules: RuleItem[] = []; let initialRules: RuleItem[] = [];
@ -427,11 +556,9 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 设置规则列表 // 设置规则列表
setRules(allRules); setRules(allRules);
setFilteredRules(allRules);
} else { } else {
// 使用初始规则 // 使用初始规则
setRules(initialRules); setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记 // 清空规则标记
setPrependRules(new Set()); setPrependRules(new Set());
setAppendRules(new Set()); setAppendRules(new Set());
@ -441,7 +568,6 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 规则文件读取失败 // 规则文件读取失败
console.debug('规则文件读取失败:', ruleError); console.debug('规则文件读取失败:', ruleError);
setRules(initialRules); setRules(initialRules);
setFilteredRules(initialRules);
// 清空规则标记 // 清空规则标记
setPrependRules(new Set()); setPrependRules(new Set());
setAppendRules(new Set()); setAppendRules(new Set());
@ -449,6 +575,8 @@ const EditRulesModal: React.FC<Props> = (props) => {
} }
} catch (e) { } catch (e) {
console.error('Failed to parse profile content', e) console.error('Failed to parse profile content', e)
} finally {
setIsLoading(false)
} }
} }
@ -456,21 +584,28 @@ const EditRulesModal: React.FC<Props> = (props) => {
getContent() getContent()
}, []) }, [])
useEffect(() => { const validateRulePayload = useCallback((ruleType: string, payload: string): boolean => {
if (searchTerm === '') { if (ruleType === 'MATCH') {
setFilteredRules(rules) return true;
} 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> => { const rule = ruleDefinitionsMap.get(ruleType);
const validator = rule?.validator;
if (!validator) {
return true;
}
return validator(payload);
}, []);
const isPayloadValid = useMemo(() => {
if (newRule.type === 'MATCH' || !newRule.payload) {
return true;
}
return validateRulePayload(newRule.type, newRule.payload);
}, [newRule.type, newRule.payload, validateRulePayload]);
const handleSave = useCallback(async (): Promise<void> => {
try { try {
// 保存规则到文件 // 保存规则到文件
const prependRuleStrings = Array.from(prependRules) const prependRuleStrings = Array.from(prependRules)
@ -509,7 +644,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
} catch (e) { } 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)));
} }
} }, [prependRules, deletedRules, rules, appendRules, id, onClose, t])
const handleRuleTypeChange = (selected: string): void => { const handleRuleTypeChange = (selected: string): void => {
const noResolveSupported = isRuleSupportsNoResolve(selected); const noResolveSupported = isRuleSupportsNoResolve(selected);
@ -547,14 +682,19 @@ const EditRulesModal: React.FC<Props> = (props) => {
}); });
}; };
const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => { const handleAddRule = useCallback((position: 'prepend' | 'append' = 'append'): void => {
if (newRule.type === 'MATCH' || newRule.payload.trim() !== '') { if (!(newRule.type === 'MATCH' || newRule.payload.trim() !== '')) {
if (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)) { return;
alert(t('profiles.editRules.invalidPayload') + ': ' + getRuleExample(newRule.type)); }
return;
}
const newRuleItem = { ...newRule }; if (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)) {
alert(t('profiles.editRules.invalidPayload') + ': ' + getRuleExample(newRule.type));
return;
}
const newRuleItem = { ...newRule };
startTransition(() => {
let updatedRules: RuleItem[]; let updatedRules: RuleItem[];
if (position === 'prepend') { if (position === 'prepend') {
@ -568,7 +708,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 更新规则索引 // 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, true); const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, true);
// 更新状态 // 批量更新状态
setPrependRules(newPrependRules); setPrependRules(newPrependRules);
setAppendRules(newAppendRules); setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules); setDeletedRules(newDeletedRules);
@ -584,7 +724,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 更新规则索引 // 更新规则索引
const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, false, true); const { newPrependRules, newAppendRules, newDeletedRules } = updateAllRuleIndicesAfterInsertion(prependRules, appendRules, deletedRules, insertPosition, false, true);
// 更新状态 // 批量更新状态
setPrependRules(newPrependRules); setPrependRules(newPrependRules);
setAppendRules(newAppendRules); setAppendRules(newAppendRules);
setDeletedRules(newDeletedRules); setDeletedRules(newDeletedRules);
@ -592,12 +732,11 @@ const EditRulesModal: React.FC<Props> = (props) => {
// 更新规则列表 // 更新规则列表
setRules(updatedRules); setRules(updatedRules);
setFilteredRules(updatedRules); });
setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }); setNewRule({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] });
} }, [newRule, rules, prependRules, appendRules, deletedRules, validateRulePayload, t])
}
const handleRemoveRule = (index: number): void => { const handleRemoveRule = useCallback((index: number): void => {
setDeletedRules(prev => { setDeletedRules(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
if (newSet.has(index)) { if (newSet.has(index)) {
@ -607,99 +746,71 @@ const EditRulesModal: React.FC<Props> = (props) => {
} }
return newSet; return newSet;
}); });
} }, [])
const handleMoveRuleUp = (index: number): void => { const handleMoveRuleUp = useCallback((index: number): void => {
if (index <= 0) return; if (index <= 0) return;
const updatedRules = [...rules]; startTransition(() => {
const temp = updatedRules[index]; const updatedRules = [...rules];
updatedRules[index] = updatedRules[index - 1]; const temp = updatedRules[index];
updatedRules[index - 1] = temp; updatedRules[index] = updatedRules[index - 1];
updatedRules[index - 1] = temp;
// 更新前置规则偏移量 // 更新前置规则偏移量
if (prependRules.has(index)) { if (prependRules.has(index)) {
updatedRules[index - 1] = { updatedRules[index - 1] = {
...updatedRules[index - 1], ...updatedRules[index - 1],
offset: Math.max(0, (updatedRules[index - 1].offset || 0) - 1) offset: Math.max(0, (updatedRules[index - 1].offset || 0) - 1)
}; };
} }
// 更新后置规则偏移量 // 更新后置规则偏移量
if (appendRules.has(index)) { if (appendRules.has(index)) {
updatedRules[index - 1] = { updatedRules[index - 1] = {
...updatedRules[index - 1], ...updatedRules[index - 1],
offset: (updatedRules[index - 1].offset || 0) + 1 offset: (updatedRules[index - 1].offset || 0) + 1
}; };
} }
// 首先更新规则数组 // 批量更新状态
setRules(updatedRules); 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));
});
}, [rules, prependRules, appendRules])
// 更新删除规则索引 const handleMoveRuleDown = useCallback((index: number): void => {
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; if (index >= rules.length - 1) return;
const updatedRules = [...rules]; startTransition(() => {
const temp = updatedRules[index]; const updatedRules = [...rules];
updatedRules[index] = updatedRules[index + 1]; const temp = updatedRules[index];
updatedRules[index + 1] = temp; updatedRules[index] = updatedRules[index + 1];
updatedRules[index + 1] = temp;
// 更新前置规则偏移量 // 更新前置规则偏移量
if (prependRules.has(index)) { if (prependRules.has(index)) {
updatedRules[index + 1] = { updatedRules[index + 1] = {
...updatedRules[index + 1], ...updatedRules[index + 1],
offset: (updatedRules[index + 1].offset || 0) + 1 offset: (updatedRules[index + 1].offset || 0) + 1
}; };
} }
// 更新后置规则偏移量 // 更新后置规则偏移量
if (appendRules.has(index)) { if (appendRules.has(index)) {
updatedRules[index + 1] = { updatedRules[index + 1] = {
...updatedRules[index + 1], ...updatedRules[index + 1],
offset: Math.max(0, (updatedRules[index + 1].offset || 0) - 1) offset: Math.max(0, (updatedRules[index + 1].offset || 0) - 1)
}; };
} }
// 首先更新规则数组 // 批量更新状态
setRules(updatedRules); 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));
setDeletedRules(prev => updateRuleIndices(prev, index, index + 1)); });
}, [rules, prependRules, appendRules])
// 更新前置规则索引
setPrependRules(prev => updateRuleIndices(prev, index, index + 1));
// 更新后置规则索引
setAppendRules(prev => updateRuleIndices(prev, index, index + 1));
}
const validateRulePayload = (ruleType: string, payload: string): boolean => {
if (ruleType === 'MATCH') {
return true;
}
const validator = getRuleValidator(ruleType);
if (!validator) {
return true;
}
return validator(payload);
};
const getRuleValidator = (ruleType: string): ((value: string) => boolean) | undefined => {
const rule = ruleDefinitionsMap.get(ruleType);
return rule?.validator;
};
// 解析规则字符串 // 解析规则字符串
const parseRuleString = (ruleStr: string): RuleItem => { const parseRuleString = (ruleStr: string): RuleItem => {
@ -911,7 +1022,7 @@ const EditRulesModal: React.FC<Props> = (props) => {
value={newRule.payload} value={newRule.payload}
onValueChange={(value) => setNewRule({ ...newRule, payload: value })} onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
isDisabled={newRule.type === 'MATCH'} isDisabled={newRule.type === 'MATCH'}
color={newRule.payload && newRule.type !== 'MATCH' && !validateRulePayload(newRule.type, newRule.payload) ? "danger" : "default"} color={newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? "danger" : "default"}
/> />
<Autocomplete <Autocomplete
@ -998,7 +1109,11 @@ const EditRulesModal: React.FC<Props> = (props) => {
/> />
</div> </div>
<div className="flex flex-col gap-2 max-h-[calc(100vh-200px)] overflow-y-auto flex-1"> <div className="flex flex-col gap-2 max-h-[calc(100vh-200px)] overflow-y-auto flex-1">
{filteredRules.length === 0 ? ( {isLoading ? (
<div className="flex items-center justify-center h-full py-8">
<Spinner size="lg" label={t('common.loading') || 'Loading...'} />
</div>
) : filteredRules.length === 0 ? (
<div className="text-center text-foreground-500 py-4"> <div className="text-center text-foreground-500 py-4">
{rules.length === 0 {rules.length === 0
? t('profiles.editRules.noRules') ? t('profiles.editRules.noRules')
@ -1007,72 +1122,23 @@ const EditRulesModal: React.FC<Props> = (props) => {
: t('profiles.editRules.noRules')} : t('profiles.editRules.noRules')}
</div> </div>
) : ( ) : (
filteredRules.map((rule, index) => { deferredFilteredRules.map((rule, index) => {
const originalIndex = rules.indexOf(rule); const originalIndex = ruleIndexMap.get(rule) ?? -1;
let bgColorClass = 'bg-content2'; const isDeleted = deletedRules.has(originalIndex);
let textStyleClass = ''; const isPrependOrAppend = prependRules.has(originalIndex) || appendRules.has(originalIndex);
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 ( return (
<div key={`${originalIndex}-${index}`} className={`flex items-center gap-2 p-2 rounded-lg ${bgColorClass}`}> <RuleListItem
<div className="flex flex-col"> key={`${originalIndex}-${index}`}
<div className="flex items-center gap-1"> rule={rule}
<Chip size="sm" variant="flat"> originalIndex={originalIndex}
{rule.type} isDeleted={isDeleted}
</Chip> isPrependOrAppend={isPrependOrAppend}
{/* 显示附加参数 */} rulesLength={rules.length}
<div className="flex gap-1"> onMoveUp={handleMoveRuleUp}
{rule.additionalParams && rule.additionalParams.length > 0 && ( onMoveDown={handleMoveRuleDown}
rule.additionalParams.map((param, idx) => ( onRemove={handleRemoveRule}
<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 ${textStyleClass}`}>
{rule.type === 'MATCH' ? rule.proxy : rule.payload}
</div>
{rule.proxy && rule.type !== 'MATCH' && (
<div className={`text-sm text-foreground-500 truncate ${textStyleClass}`}>{rule.proxy}</div>
)}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && handleMoveRuleUp(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === 0 || deletedRules.has(originalIndex)}
>
<IoMdArrowUp className="text-lg" />
</Button>
<Button
size="sm"
variant="light"
onPress={() => originalIndex !== -1 && handleMoveRuleDown(originalIndex)}
isIconOnly
isDisabled={originalIndex === -1 || originalIndex === rules.length - 1 || deletedRules.has(originalIndex)}
>
<IoMdArrowDown className="text-lg" />
</Button>
<Button
size="sm"
color={originalIndex !== -1 && deletedRules.has(originalIndex) ? "success" : "danger"}
variant="light"
onPress={() => originalIndex !== -1 && handleRemoveRule(originalIndex)}
isIconOnly
>
{originalIndex !== -1 && deletedRules.has(originalIndex) ? <IoMdUndo className="text-lg" /> : <IoMdTrash className="text-lg" />}
</Button>
</div>
</div>
) )
}) })
)} )}