From f953dca228b2fab4d8dd2f4d004d5c5897d1853a Mon Sep 17 00:00:00 2001 From: ezequielnick <107352853+ezequielnick@users.noreply.github.com> Date: Sat, 15 Nov 2025 21:01:59 +0800 Subject: [PATCH] perf: rule edit page --- .../components/profiles/edit-rules-modal.tsx | 502 ++++++++++-------- 1 file changed, 284 insertions(+), 218 deletions(-) diff --git a/src/renderer/src/components/profiles/edit-rules-modal.tsx b/src/renderer/src/components/profiles/edit-rules-modal.tsx index e1c4808..2387c54 100644 --- a/src/renderer/src/components/profiles/edit-rules-modal.tsx +++ b/src/renderer/src/components/profiles/edit-rules-modal.tsx @@ -1,9 +1,9 @@ -import { - Modal, - ModalContent, - ModalHeader, - ModalBody, - ModalFooter, +import { + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, Button, Chip, Input, @@ -12,9 +12,10 @@ import { Autocomplete, AutocompleteItem, Checkbox, - Divider + Divider, + Spinner } 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 { useTranslation } from 'react-i18next' import yaml from 'js-yaml' @@ -278,27 +279,155 @@ const isAddRuleDisabled = (newRule: RuleItem, validateRulePayload: (ruleType: st (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(({ + 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 ( +
+
+
+ + {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}
+ )} +
+
+ + + +
+
+ ); +}, (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) => { const { id, onClose } = props const [rules, setRules] = useState([]) - const [filteredRules, setFilteredRules] = useState([]) const [, setProfileContent] = useState('') const [newRule, setNewRule] = useState({ type: 'DOMAIN', payload: '', proxy: 'DIRECT', additionalParams: [] }) const [searchTerm, setSearchTerm] = useState('') + const [deferredSearchTerm, setDeferredSearchTerm] = useState('') const [proxyGroups, setProxyGroups] = useState([]) const [deletedRules, setDeletedRules] = useState>(new Set()) const [prependRules, setPrependRules] = useState>(new Set()) const [appendRules, setAppendRules] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(true) const { t } = useTranslation() + const ruleIndexMap = useMemo(() => { + const map = new Map() + 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 => { - const content = await getProfileStr(id) - setProfileContent(content) - + setIsLoading(true) try { + const content = await getProfileStr(id) + setProfileContent(content) + const parsed = yaml.load(content) as any let initialRules: RuleItem[] = []; - + if (parsed && parsed.rules && Array.isArray(parsed.rules)) { initialRules = parsed.rules.map((rule: string) => { const parts = rule.split(',') @@ -424,14 +553,12 @@ const EditRulesModal: React.FC = (props) => { setPrependRules(newPrependRules); setAppendRules(newAppendRules); setDeletedRules(newDeletedRules); - + // 设置规则列表 setRules(allRules); - setFilteredRules(allRules); } else { // 使用初始规则 setRules(initialRules); - setFilteredRules(initialRules); // 清空规则标记 setPrependRules(new Set()); setAppendRules(new Set()); @@ -441,7 +568,6 @@ const EditRulesModal: React.FC = (props) => { // 规则文件读取失败 console.debug('规则文件读取失败:', ruleError); setRules(initialRules); - setFilteredRules(initialRules); // 清空规则标记 setPrependRules(new Set()); setAppendRules(new Set()); @@ -449,6 +575,8 @@ const EditRulesModal: React.FC = (props) => { } } catch (e) { console.error('Failed to parse profile content', e) + } finally { + setIsLoading(false) } } @@ -456,31 +584,38 @@ const EditRulesModal: React.FC = (props) => { 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) + const validateRulePayload = useCallback((ruleType: string, payload: string): boolean => { + if (ruleType === 'MATCH') { + return true; } - }, [searchTerm, rules]) - const handleSave = async (): Promise => { + 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 => { try { // 保存规则到文件 const prependRuleStrings = Array.from(prependRules) .filter(index => !deletedRules.has(index) && index < rules.length) .map(index => convertRuleToString(rules[index])); - + 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)) @@ -494,14 +629,14 @@ const EditRulesModal: React.FC = (props) => { } return parts.join(','); }); - + // 创建规则数据对象 const ruleData = { prepend: prependRuleStrings, append: appendRuleStrings, delete: deletedRuleStrings }; - + // 保存到 YAML 文件 const ruleYaml = yaml.dump(ruleData); await setRuleStr(id, ruleYaml); @@ -509,7 +644,7 @@ const EditRulesModal: React.FC = (props) => { } catch (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 noResolveSupported = isRuleSupportsNoResolve(selected); @@ -547,57 +682,61 @@ const EditRulesModal: React.FC = (props) => { }); }; - const handleAddRule = (position: 'prepend' | 'append' = 'append'): void => { - if (newRule.type === 'MATCH' || newRule.payload.trim() !== '') { - if (newRule.type !== 'MATCH' && newRule.payload.trim() !== '' && !validateRulePayload(newRule.type, newRule.payload)) { - alert(t('profiles.editRules.invalidPayload') + ': ' + getRuleExample(newRule.type)); - return; - } - - const newRuleItem = { ...newRule }; + const handleAddRule = useCallback((position: 'prepend' | 'append' = 'append'): void => { + if (!(newRule.type === 'MATCH' || newRule.payload.trim() !== '')) { + return; + } + + 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[]; - + if (position === 'prepend') { // 前置规则插入 - const insertPosition = newRuleItem.offset !== undefined ? + 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 { // 后置规则插入 - const insertPosition = newRuleItem.offset !== undefined ? - Math.max(0, rules.length - newRuleItem.offset) : + 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: [] }); - } - } + }); + 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 => { const newSet = new Set(prev); if (newSet.has(index)) { @@ -607,100 +746,72 @@ const EditRulesModal: React.FC = (props) => { } return newSet; }); - } + }, []) - const handleMoveRuleUp = (index: number): void => { + const handleMoveRuleUp = useCallback((index: number): void => { 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) - }; - } - - // 更新后置规则偏移量 - 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)); - } + startTransition(() => { + const updatedRules = [...rules]; + const temp = updatedRules[index]; + updatedRules[index] = updatedRules[index - 1]; + updatedRules[index - 1] = temp; - const handleMoveRuleDown = (index: number): void => { + // 更新前置规则偏移量 + if (prependRules.has(index)) { + updatedRules[index - 1] = { + ...updatedRules[index - 1], + offset: Math.max(0, (updatedRules[index - 1].offset || 0) - 1) + }; + } + + // 更新后置规则偏移量 + if (appendRules.has(index)) { + updatedRules[index - 1] = { + ...updatedRules[index - 1], + offset: (updatedRules[index - 1].offset || 0) + 1 + }; + } + + // 批量更新状态 + setRules(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 => { 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 - }; - } - - // 更新后置规则偏移量 - 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)); - } + startTransition(() => { + const updatedRules = [...rules]; + const temp = updatedRules[index]; + updatedRules[index] = updatedRules[index + 1]; + updatedRules[index + 1] = temp; - const validateRulePayload = (ruleType: string, payload: string): boolean => { - if (ruleType === 'MATCH') { - return true; - } + // 更新前置规则偏移量 + if (prependRules.has(index)) { + updatedRules[index + 1] = { + ...updatedRules[index + 1], + offset: (updatedRules[index + 1].offset || 0) + 1 + }; + } - const validator = getRuleValidator(ruleType); - if (!validator) { - return true; - } + // 更新后置规则偏移量 + if (appendRules.has(index)) { + updatedRules[index + 1] = { + ...updatedRules[index + 1], + offset: Math.max(0, (updatedRules[index + 1].offset || 0) - 1) + }; + } - return validator(payload); - }; + // 批量更新状态 + setRules(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 getRuleValidator = (ruleType: string): ((value: string) => boolean) | undefined => { - const rule = ruleDefinitionsMap.get(ruleType); - return rule?.validator; - }; - // 解析规则字符串 const parseRuleString = (ruleStr: string): RuleItem => { const parts = ruleStr.split(','); @@ -911,7 +1022,7 @@ const EditRulesModal: React.FC = (props) => { value={newRule.payload} onValueChange={(value) => setNewRule({ ...newRule, payload: value })} 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"} /> = (props) => { />
- {filteredRules.length === 0 ? ( + {isLoading ? ( +
+ +
+ ) : filteredRules.length === 0 ? (
- {rules.length === 0 - ? t('profiles.editRules.noRules') - : searchTerm - ? t('profiles.editRules.noMatchingRules') + {rules.length === 0 + ? t('profiles.editRules.noRules') + : searchTerm + ? t('profiles.editRules.noMatchingRules') : t('profiles.editRules.noRules')}
) : ( - 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'; - } - + deferredFilteredRules.map((rule, index) => { + const originalIndex = ruleIndexMap.get(rule) ?? -1; + const isDeleted = deletedRules.has(originalIndex); + const isPrependOrAppend = prependRules.has(originalIndex) || appendRules.has(originalIndex); + 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}
- )} -
-
- - - -
-
+ ) }) )}