fix: group tools filter and layout (#7431)

* fix(proxy): group filter

* refactor(proxy): improve layout and structure of proxy render components

* refactor(proxy): remove unnecessary flushSync calls in proxy-group-tools

* feat(proxy): debounce filter input in ProxyGroupTools

* refactor(base-search-box): initialize lastSearchStateRef with current state

* refactor(proxy): remove debug console.log in proxy-groups

* refactor(proxy): improve scrolling behavior in proxy groups

* refactor(proxy): improve scroll position restoration logic

* chore(proxy): reset proxy group tools layout

* style(proxy): adjust margins and spacing in proxy components

* refactor(proxy): remove commented out ProxyGroupTools component
This commit is contained in:
oomeow
2026-07-11 07:38:32 +00:00
committed by GitHub
parent 562828e14d
commit 1847002ebe
5 changed files with 195 additions and 116 deletions
+9 -1
View File
@@ -86,7 +86,15 @@ export const BaseSearchBox = ({
}: SearchProps) => {
const { t } = useTranslation()
const onSearchRef = useRef(onSearch)
const lastSearchStateRef = useRef<SearchState | null>(null)
// 用初始状态初始化,使挂载时的 emitSearch 因状态相同而被跳过,
// 从而只在输入内容真正变化时才触发 onSearch(也可避免 StrictMode 下的重复调用)。
const lastSearchStateRef = useRef<SearchState | null>({
text: value ?? defaultValue ?? '',
matchCase: searchState?.matchCase ?? defaultMatchCase,
matchWholeWord: searchState?.matchWholeWord ?? defaultMatchWholeWord,
useRegularExpression:
searchState?.useRegularExpression ?? defaultUseRegularExpression,
})
const [text, setText] = useControllableState<string>({
controlled: value,
+1 -4
View File
@@ -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
+34 -22
View File
@@ -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 (
<Box
sx={{
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
gap: 0.5,
height: 36,
flex: 1,
ml: 2,
...sx,
}}
>
{textState === 'filter' && (
<Box sx={{ display: 'inline-block', width: 180 }}>
<Box sx={{ flex: '1 1 auto' }}>
<BaseSearchBox
value={filterText}
defaultValue={filterText}
matchCase={filterMatchCase}
matchWholeWord={filterMatchWholeWord}
useRegularExpression={filterUseRegularExpression}
onClick={(e) => {
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)}
/>
</Box>
)}
@@ -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 } }}
/>
)}
<IconButton
size="small"
color="inherit"
@@ -235,10 +250,7 @@ export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) {
if (!headState.open)
// eslint-disable-next-line @eslint-react/dom-no-flush-sync
flushSync(() => onHeadState({ open: true }))
onHeadState({
textState: textState === 'filter' ? null : 'filter',
filterText: '',
})
onHeadState({ textState: textState === 'filter' ? null : 'filter' })
}}
>
{textState === 'filter' ? (
+50 -9
View File
@@ -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,
],
)
+101 -80
View File
@@ -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') && (
<img
src={iconCachePath === '' ? group.icon : iconCachePath}
alt="group icon"
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('data') && (
<img
src={group.icon}
alt="group icon"
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
alt="group icon"
width="32px"
/>
)}
<ListItemText
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box sx={{ width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
{enable_group_icon && group.icon?.trim().startsWith('http') && (
<img
src={iconCachePath === '' ? group.icon : iconCachePath}
alt="group icon"
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('data') && (
<img
src={group.icon}
alt="group icon"
width="32px"
style={{ marginRight: '12px', borderRadius: '6px' }}
/>
)}
{enable_group_icon && group.icon?.trim().startsWith('<svg') && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
alt="group icon"
width="32px"
/>
)}
<ListItemText
sx={{ flex: '0 1 auto', minWidth: 0 }}
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box
sx={{
display: 'flex',
alignItems: 'center',
pt: '2px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
<Box
component="span"
sx={{
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
}
slotProps={{
secondary: {
component: 'div',
sx: {
display: 'flex',
alignItems: 'center',
color: '#ccc',
},
},
}}
/>
<Box
sx={{
display: 'flex',
alignItems: 'center',
pt: '2px',
overflow: 'hidden',
whiteSpace: 'nowrap',
}}
>
<Box
component="span"
sx={{
marginTop: '2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: 'text.secondary' }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
}
slotProps={{
secondary: {
component: 'div',
sx: { display: 'flex', alignItems: 'center', color: '#ccc' },
},
}}
/>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<ProxyGroupTools
url={group.testUrl}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<div
style={{
minWidth: '50px',
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
flex: '1 1 auto',
minWidth: 0,
}}
>
<Chip
size="small"
label={`${group.all.length}`}
sx={{
mr: 1,
backgroundColor: (theme) =>
alpha(theme.palette.primary.main, 0.1),
color: (theme) => theme.palette.primary.main,
}}
<ProxyGroupTools
url={group.testUrl}
groupName={group.name}
headState={headState!}
onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
</div>
</Tooltip>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
<Tooltip title={t('proxies.page.labels.proxyCount')} arrow>
<div
style={{
minWidth: '50px',
display: 'flex',
justifyContent: 'end',
alignItems: 'center',
}}
>
<Chip
size="small"
label={`${group.all.length}`}
sx={{
mr: 1,
backgroundColor: (theme) =>
alpha(theme.palette.primary.main, 0.1),
color: (theme) => theme.palette.primary.main,
}}
/>
</div>
</Tooltip>
{headState?.open ? (
<ExpandLessRounded />
) : (
<ExpandMoreRounded />
)}
</Box>
</Box>
</Box>
</ListItemButton>
</div>