diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index bd2da35aa..0eecc792b 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -86,7 +86,15 @@ export const BaseSearchBox = ({ }: SearchProps) => { const { t } = useTranslation() const onSearchRef = useRef(onSearch) - const lastSearchStateRef = useRef(null) + // 用初始状态初始化,使挂载时的 emitSearch 因状态相同而被跳过, + // 从而只在输入内容真正变化时才触发 onSearch(也可避免 StrictMode 下的重复调用)。 + const lastSearchStateRef = useRef({ + text: value ?? defaultValue ?? '', + matchCase: searchState?.matchCase ?? defaultMatchCase, + matchWholeWord: searchState?.matchWholeWord ?? defaultMatchWholeWord, + useRegularExpression: + searchState?.useRegularExpression ?? defaultUseRegularExpression, + }) const [text, setText] = useControllableState({ controlled: value, diff --git a/src/components/base/sticky-virtual-list.tsx b/src/components/base/sticky-virtual-list.tsx index 8ccebd078..f65a2a8b3 100644 --- a/src/components/base/sticky-virtual-list.tsx +++ b/src/components/base/sticky-virtual-list.tsx @@ -279,10 +279,7 @@ export const StickyVirtualList = forwardRef(function StickyVirtualListInner< transform: `translateY(${virtualRow.start}px)`, width: '100%', zIndex: 1, - ...(isGroup && { - opacity: 0, - zIndex: -10, - }), + ...(isGroup && { visibility: 'hidden' }), }} > {isGroup diff --git a/src/components/proxy/proxy-group-tools.tsx b/src/components/proxy/proxy-group-tools.tsx index ea30352af..fa39b2770 100644 --- a/src/components/proxy/proxy-group-tools.tsx +++ b/src/components/proxy/proxy-group-tools.tsx @@ -10,6 +10,7 @@ import VisibilityRounded from '@mui/icons-material/VisibilityRounded' import WifiTetheringOffRounded from '@mui/icons-material/WifiTetheringOffRounded' import WifiTetheringRounded from '@mui/icons-material/WifiTetheringRounded' import { Box, IconButton, type SxProps, TextField } from '@mui/material' +import { useDebounceFn } from 'ahooks' import { memo, useEffect } from 'react' import { flushSync } from 'react-dom' import { useTranslation } from 'react-i18next' @@ -17,7 +18,7 @@ import { useTranslation } from 'react-i18next' import { useVerge } from '@/hooks/use-verge' import delayManager from '@/services/delay' -import { BaseSearchBox } from '../base' +import { BaseSearchBox, type SearchState } from '../base' import type { ProxySortType } from './use-filter-sort' import type { HeadState } from './use-head-state' @@ -65,36 +66,51 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) }, [groupName, testUrl, defaultLatencyUrl, url]) + // 过滤输入是高频操作,且每次都会触发整组代理的重新过滤/排序与虚拟列表重渲染, + // 因此对写入 headState 的动作做防抖,避免每输入一个字符就过滤一次。 + const { run: applyFilter, flush: flushFilter } = useDebounceFn( + (state: SearchState) => { + onHeadState({ + filterText: state.text, + filterMatchCase: state.matchCase, + filterMatchWholeWord: state.matchWholeWord, + filterUseRegularExpression: state.useRegularExpression, + }) + }, + { wait: 300 }, + ) + + // 关闭过滤框或卸载时立即应用最后一次输入,避免丢失未生效的过滤条件。 + useEffect(() => { + if (textState !== 'filter') flushFilter() + }, [textState, flushFilter]) + useEffect(() => () => flushFilter(), [flushFilter]) + return ( {textState === 'filter' && ( - + { e.preventDefault() e.stopPropagation() }} - searchState={{ - matchCase: filterMatchCase, - matchWholeWord: filterMatchWholeWord, - useRegularExpression: filterUseRegularExpression, - }} - onSearch={(_, state) => - onHeadState({ - filterText: state.text, - filterMatchCase: state.matchCase, - filterMatchWholeWord: state.matchWholeWord, - filterUseRegularExpression: state.useRegularExpression, - }) - } + onSearch={(_, state) => applyFilter(state)} /> )} @@ -113,10 +129,9 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { e.stopPropagation() }} onChange={(e) => onHeadState({ testUrl: e.target.value })} - sx={{ width: 180, input: { py: 0.65, px: 1 } }} + sx={{ flex: '1 1 auto', input: { py: 0.65, px: 1 } }} /> )} - onHeadState({ open: true })) - onHeadState({ - textState: textState === 'filter' ? null : 'filter', - filterText: '', - }) + onHeadState({ textState: textState === 'filter' ? null : 'filter' }) }} > {textState === 'filter' ? ( diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index af5e767a5..7c52ca09c 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -354,24 +354,56 @@ function NormalProxyGroups(props: { mode: string }) { saveScrollPosition, } = useProxyRenderState(mode, false, null) const renderFirstRef = useRef(true) + // 恢复滚动位置期间设为 true,避免程序化滚动触发的 scroll 事件把中间值写回存储 + const isRestoringRef = useRef(false) // 目前无法使用 StickyVirtualList 的 initialOffset 值设置初始化,具体原因需排查 // 从 localStorage 恢复滚动位置 useLayoutEffect(() => { if (renderList.length === 0) return + if (!renderFirstRef.current) return const node = stickyListRef.current?.getScrollElement() if (!node) return - if (!renderFirstRef.current) return const savedPosition = getScrollPosition() - if (savedPosition !== undefined) { - node.scrollTop = savedPosition - if (node.scrollTop === savedPosition) { - renderFirstRef.current = false - } - } else { - // The position that hasn't been saved yet during the first render + // 未保存过位置或位置为 0(顶部)时无需恢复 + if (!savedPosition) { renderFirstRef.current = false + return + } + + // 虚拟列表初始使用预估高度,真实高度测量完成后总高度才会稳定。 + // 尤其是过滤后节点数变少时,预估总高度常常不足以一次性滚动到目标位置, + // 因此跨帧重试,直到到达目标位置(或内容确实不够高)为止。 + isRestoringRef.current = true + let rafId = 0 + let attempts = 0 + const maxAttempts = 30 + + const step = () => { + const el = stickyListRef.current?.getScrollElement() + if (!el) { + isRestoringRef.current = false + return + } + + el.scrollTop = savedPosition + attempts += 1 + + const reached = Math.abs(el.scrollTop - savedPosition) <= 1 + if (reached || attempts >= maxAttempts) { + renderFirstRef.current = false + isRestoringRef.current = false + return + } + + rafId = requestAnimationFrame(step) + } + + rafId = requestAnimationFrame(step) + return () => { + cancelAnimationFrame(rafId) + isRestoringRef.current = false } }, [renderList.length, getScrollPosition]) @@ -382,6 +414,8 @@ function NormalProxyGroups(props: { mode: string }) { const handleScroll = useCallback( (event: Event) => { + // 恢复位置过程中产生的滚动不写回存储,避免中间的钳制值覆盖真实位置 + if (isRestoringRef.current) return const target = event.target as HTMLElement | null const nextScrollTop = target?.scrollTop ?? 0 @@ -496,7 +530,13 @@ function NormalProxyGroups(props: { mode: string }) { stickyed={stickyed} onLocation={handleLocation} onCheckAll={handleCheckAll} - onHeadState={onHeadState} + onHeadState={async (groupName, patch) => { + if (stickyed && patch.filterText !== undefined) { + handleGroupLocationByName(groupName) + await stickyListRef.current?.waitForScrollEnd() + } + onHeadState(groupName, patch) + }} onChangeProxy={handleChangeProxy} onGroupToggle={handleGroupToggle} /> @@ -507,6 +547,7 @@ function NormalProxyGroups(props: { mode: string }) { onHeadState, handleLocation, handleGroupToggle, + handleGroupLocationByName, ], ) diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index 5b0992a03..15ffa8aa9 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -106,94 +106,115 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { onHeadState?.(group.name, { open: !headState?.open }) }} > - {enable_group_icon && group.icon?.trim().startsWith('http') && ( - group icon - )} - {enable_group_icon && group.icon?.trim().startsWith('data') && ( - group icon - )} - {enable_group_icon && group.icon?.trim().startsWith(' - )} - {group.name}} - secondary={ + + + {enable_group_icon && group.icon?.trim().startsWith('http') && ( + group icon + )} + {enable_group_icon && group.icon?.trim().startsWith('data') && ( + group icon + )} + {enable_group_icon && group.icon?.trim().startsWith(' + )} + {group.name}} + secondary={ + + + {group.type} + + {group.now} + + + + } + slotProps={{ + secondary: { + component: 'div', + sx: { + display: 'flex', + alignItems: 'center', + color: '#ccc', + }, + }, + }} + /> - - {group.type} - - {group.now} - - - - } - slotProps={{ - secondary: { - component: 'div', - sx: { display: 'flex', alignItems: 'center', color: '#ccc' }, - }, - }} - /> - - onLocation(group)} - onCheckDelay={() => onCheckAll(group.name)} - onHeadState={(p) => onHeadState(group.name, p)} - /> - -
- - alpha(theme.palette.primary.main, 0.1), - color: (theme) => theme.palette.primary.main, - }} + onLocation(group)} + onCheckDelay={() => onCheckAll(group.name)} + onHeadState={(p) => onHeadState(group.name, p)} /> -
-
- {headState?.open ? : } + +
+ + alpha(theme.palette.primary.main, 0.1), + color: (theme) => theme.palette.primary.main, + }} + /> +
+
+ {headState?.open ? ( + + ) : ( + + )} +
+