mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-15 07:12:09 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user