From dd3ef698f54533cbf57a52d8fd4bd7278368ec71 Mon Sep 17 00:00:00 2001 From: oomeow Date: Thu, 2 Jul 2026 21:22:40 +0800 Subject: [PATCH] feat: sticky groups (#7195) * feat(proxy): implement sticky virtual list and group tools - Add new StickyVirtualList component for better performance with sticky headers - Create ProxyGroupTools component for filtering, sorting and testing proxy groups - Enhance proxy groups with improved scrolling and group toggle functionality - Integrate search box with click event handling - Remove redundant ProxyHead component usage * style: remove unused import and update TypeScript types * refactor: convert function component to forwardRef component to fix biome lint error * feat(proxy): extract proxy chain UI to separate component * refactor: remove custom throttle implementation and simplify dependencies * feat(proxy): improve proxy groups UI and scrolling behavior - Add initialOffset prop to StickyVirtualList for better scroll position restoration - Refactor ProxyGroups component to separate chain and normal modes - Implement scroll position persistence for both chain and normal modes - Add sticky shadow effect for expanded proxy groups - Remove unnecessary ScrollTopButton component - Optimize scroll event handling with throttling - Improve code organization with custom hooks and component separation * docs(proxy-groups): Add TODO comment for initial render position issue * feat(proxy): improve scroll position handling and UI display * refactor(proxy): simplify scroll handling logic and remove unused code * fix(sticky-virtual-list): remove duplicate styles and add padding * refactor(proxy): rename scroll offset variables and functions for clarity * refactor: remove unnecessary refs and simplify onClick handlers * fix(proxy-groups): handle initial scroll position state * feat(zed): add biome formatter configuration * refactor(proxy): remove unused headState import and type annotations * style(proxy): improve type imports and conditional rendering * . * feat(proxy): add ProxyHead component to render in global mode * chore: ignore .zed directory * docs: update changelog --- Changelog.md | 3 +- src/components/base/base-loading-overlay.tsx | 1 - src/components/base/base-search-box.tsx | 6 +- src/components/base/index.ts | 4 + src/components/base/sticky-virtual-list.tsx | 301 +++++ src/components/proxy/proxy-chain.tsx | 2 +- src/components/proxy/proxy-group-tools.tsx | 252 ++++ src/components/proxy/proxy-groups-chain.tsx | 492 ++++++++ src/components/proxy/proxy-groups.tsx | 1108 +++++++----------- src/components/proxy/proxy-head.tsx | 2 +- src/components/proxy/proxy-render.tsx | 174 +-- src/components/proxy/use-render-list.ts | 15 +- src/pages/proxies.tsx | 2 +- 13 files changed, 1593 insertions(+), 769 deletions(-) create mode 100644 src/components/base/sticky-virtual-list.tsx create mode 100644 src/components/proxy/proxy-group-tools.tsx create mode 100644 src/components/proxy/proxy-groups-chain.tsx diff --git a/Changelog.md b/Changelog.md index d6cbba0af..4096e9c4d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -21,7 +21,7 @@ - 增加 TrustTunnel, OpenVPN, Tailscale, GostRelay 节点显示支持 - 全局扩展脚本增加恢复默认按钮 -- DNS 添加 fake-ip-range6 可配置项 +- DNS 添加 fake-ip-range6 可配置项 @@ -32,5 +32,6 @@ - 更健壮的 Mihomo API 通信机制 - 关闭 autofill 弹出窗口 - 改进切换订阅后激活选中节点的逻辑 +- 实现代理组粘性滚动列表 diff --git a/src/components/base/base-loading-overlay.tsx b/src/components/base/base-loading-overlay.tsx index 1ec53bdc1..40cd7829e 100644 --- a/src/components/base/base-loading-overlay.tsx +++ b/src/components/base/base-loading-overlay.tsx @@ -1,5 +1,4 @@ import { Box, CircularProgress } from '@mui/material' -import React from 'react' interface BaseLoadingOverlayProps { isLoading: boolean diff --git a/src/components/base/base-search-box.tsx b/src/components/base/base-search-box.tsx index 405fb9bdb..bd2da35aa 100644 --- a/src/components/base/base-search-box.tsx +++ b/src/components/base/base-search-box.tsx @@ -2,7 +2,8 @@ import { ClearRounded } from '@mui/icons-material' import { Box, SvgIcon, TextField, styled, IconButton } from '@mui/material' import Tooltip from '@mui/material/Tooltip' import { - ChangeEvent, + type ChangeEvent, + type MouseEvent, useCallback, useEffect, useMemo, @@ -35,6 +36,7 @@ type SearchProps = { useRegularExpression?: boolean searchState?: Partial onSearch: (match: (content: string) => boolean, state: SearchState) => void + onClick?: (e: MouseEvent) => void } const StyledTextField = styled(TextField)(({ theme }) => ({ @@ -80,6 +82,7 @@ export const BaseSearchBox = ({ matchWholeWord: defaultMatchWholeWord = false, useRegularExpression: defaultUseRegularExpression = false, onSearch, + onClick, }: SearchProps) => { const { t } = useTranslation() const onSearchRef = useRef(onSearch) @@ -199,6 +202,7 @@ export const BaseSearchBox = ({ placeholder={placeholder ?? t('shared.placeholders.filter')} sx={{ input: { py: 0.65, px: 1.25 } }} value={text} + onClick={onClick} onChange={handleChangeText} error={!!effectiveErrorMessage} slotProps={{ diff --git a/src/components/base/index.ts b/src/components/base/index.ts index 5463e53aa..e1ddd9459 100644 --- a/src/components/base/index.ts +++ b/src/components/base/index.ts @@ -16,3 +16,7 @@ export { MonacoEditor } from './monaco-editor' export { Switch } from './base-switch' export { TooltipIcon } from './base-tooltip-icon' export { VirtualList, type VirtualListHandle } from './virtual-list' +export { + StickyVirtualList, + type StickyVirtualListHandle, +} from './sticky-virtual-list' diff --git a/src/components/base/sticky-virtual-list.tsx b/src/components/base/sticky-virtual-list.tsx new file mode 100644 index 000000000..8ccebd078 --- /dev/null +++ b/src/components/base/sticky-virtual-list.tsx @@ -0,0 +1,301 @@ +import { useVirtualizer } from '@tanstack/react-virtual' +import { + forwardRef, + type ReactNode, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' + +type ScrollToIndexOptions = { + align?: 'auto' | 'center' | 'end' | 'start' + behavior?: ScrollBehavior +} + +const findGroupSectionIndex = (groupIndexes: number[], itemIndex: number) => { + let low = 0 + let high = groupIndexes.length - 1 + let matchedIndex = -1 + + while (low <= high) { + const middle = Math.floor((low + high) / 2) + + if (groupIndexes[middle] <= itemIndex) { + matchedIndex = middle + low = middle + 1 + } else { + high = middle - 1 + } + } + + return matchedIndex +} + +export interface StickyVirtualListHandle { + getScrollElement: () => HTMLDivElement | null + isItemScrolledPastStart: (index: number, tolerance?: number) => boolean + scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void + isScrolling: () => boolean + waitForScrollEnd: () => Promise +} + +export interface StickyVirtualListProps { + initialOffset?: number + items: TItem[] + isGroupItem: (item: TItem, index: number) => boolean + getItemKey: (item: TItem, index: number) => React.Key + // 组项预估高度 + estimateGroupItemHeight: number + // 非组项预估高度 + estimateItemHeight: number + renderGroupItem: (item: TItem, index: number, stickyed: boolean) => ReactNode + renderItem: (item: TItem, index: number) => ReactNode + className?: string + style?: React.CSSProperties + overscan?: number +} + +export const StickyVirtualList = forwardRef(function StickyVirtualListInner< + TItem, +>( + props: StickyVirtualListProps, + ref: React.ForwardedRef, +) { + const { + initialOffset = 0, + items, + isGroupItem, + getItemKey, + estimateGroupItemHeight, + estimateItemHeight, + renderGroupItem, + renderItem, + className, + style, + overscan = 8, + } = props + const scrollParentRef = useRef(null) + const getEstimatedItemHeight = useCallback( + (index: number) => + isGroupItem(items[index], index) + ? estimateGroupItemHeight + : estimateItemHeight, + [estimateGroupItemHeight, estimateItemHeight, isGroupItem, items], + ) + + const groupIndexes = useMemo( + () => + items.reduce((indexes, item, index) => { + if (isGroupItem(item, index)) indexes.push(index) + return indexes + }, []), + [isGroupItem, items], + ) + + const groupSections = useMemo( + () => + groupIndexes.map((groupIndex, index) => ({ + groupIndex, + nextGroupIndex: groupIndexes[index + 1] ?? items.length, + })), + [groupIndexes, items.length], + ) + + const estimatedOffsets = useMemo(() => { + const offsets = new Array(items.length + 1) + offsets[0] = 0 + + for (let i = 0; i < items.length; i++) { + offsets[i + 1] = offsets[i] + getEstimatedItemHeight(i) + } + + return offsets + }, [getEstimatedItemHeight, items.length]) + + const rowVirtualizer = useVirtualizer({ + initialOffset, + count: items.length, + estimateSize: getEstimatedItemHeight, + getItemKey: (index) => getItemKey(items[index], index), + getScrollElement: () => scrollParentRef.current, + overscan, + }) + + const virtualItems = rowVirtualizer.getVirtualItems() + + const getVirtualOffset = useCallback( + (index: number) => { + return ( + rowVirtualizer.measurementsCache[index]?.start ?? + estimatedOffsets[index] + ) + }, + [estimatedOffsets, rowVirtualizer], + ) + + const visibleGroupSections = useMemo(() => { + if (!virtualItems.length || !groupSections.length) return [] + + const firstVirtualIndex = virtualItems[0].index + const lastVirtualIndex = virtualItems[virtualItems.length - 1].index + const matchedFirstSectionIndex = findGroupSectionIndex( + groupIndexes, + firstVirtualIndex, + ) + const lastSectionIndex = findGroupSectionIndex( + groupIndexes, + lastVirtualIndex, + ) + + if (lastSectionIndex < 0) return [] + + const firstSectionIndex = + matchedFirstSectionIndex >= 0 ? matchedFirstSectionIndex : 0 + + return groupSections.slice( + firstSectionIndex, + Math.min(lastSectionIndex + 2, groupSections.length), + ) + }, [groupIndexes, groupSections, virtualItems]) + + const isGroupSticky = useCallback( + (groupIndex: number, tolerance = 0) => { + const scroller = scrollParentRef.current + if (!scroller) return false + + return scroller.scrollTop > getVirtualOffset(groupIndex) + tolerance + }, + [getVirtualOffset], + ) + + useImperativeHandle( + ref, + () => ({ + getScrollElement: () => scrollParentRef.current, + isItemScrolledPastStart: (index, tolerance = 0) => { + return isGroupSticky(index, tolerance) + }, + scrollToIndex: (index, options) => { + rowVirtualizer.scrollToIndex(index, options) + }, + isScrolling: () => rowVirtualizer.isScrolling, + waitForScrollEnd: () => { + return new Promise((resolve) => { + let maxCheckCount = 5 + const interval = setInterval(() => { + if (!rowVirtualizer.isScrolling || maxCheckCount < 0) { + clearInterval(interval) + resolve() + return + } + + maxCheckCount -= 1 + }, 100) + }) + }, + }), + [isGroupSticky, rowVirtualizer], + ) + + return ( +
+
+
+ {visibleGroupSections.map(({ groupIndex, nextGroupIndex }) => { + const group = items[groupIndex] + const start = getVirtualOffset(groupIndex) + const end = + nextGroupIndex < items.length + ? getVirtualOffset(nextGroupIndex) + : rowVirtualizer.getTotalSize() + const stickyed = isGroupSticky(groupIndex, 1) + + return ( +
+
+ {renderGroupItem(group, groupIndex, stickyed)} +
+
+ ) + })} +
+ + {virtualItems.map((virtualRow) => { + const item = items[virtualRow.index] + const isGroup = isGroupItem(item, virtualRow.index) + + return ( +
+ {isGroup + ? renderGroupItem(item, virtualRow.index, false) // 渲染组,以便动态计算组高度 + : renderItem(item, virtualRow.index)} +
+ ) + })} +
+
+ ) +}) as ( + props: StickyVirtualListProps & { + ref?: React.Ref + }, +) => React.ReactElement diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 68bd78385..0b77d5921 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -1,7 +1,7 @@ import { closestCenter, DndContext, - DragEndEvent, + type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, diff --git a/src/components/proxy/proxy-group-tools.tsx b/src/components/proxy/proxy-group-tools.tsx new file mode 100644 index 000000000..ea30352af --- /dev/null +++ b/src/components/proxy/proxy-group-tools.tsx @@ -0,0 +1,252 @@ +import AccessTimeRounded from '@mui/icons-material/AccessTimeRounded' +import FilterAltOffRounded from '@mui/icons-material/FilterAltOffRounded' +import FilterAltRounded from '@mui/icons-material/FilterAltRounded' +import MyLocationRounded from '@mui/icons-material/MyLocationRounded' +import NetworkCheckRounded from '@mui/icons-material/NetworkCheckRounded' +import SortByAlphaRounded from '@mui/icons-material/SortByAlphaRounded' +import SortRounded from '@mui/icons-material/SortRounded' +import VisibilityOffRounded from '@mui/icons-material/VisibilityOffRounded' +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 { memo, useEffect } from 'react' +import { flushSync } from 'react-dom' +import { useTranslation } from 'react-i18next' + +import { useVerge } from '@/hooks/use-verge' +import delayManager from '@/services/delay' + +import { BaseSearchBox } from '../base' + +import type { ProxySortType } from './use-filter-sort' +import type { HeadState } from './use-head-state' + +interface Props { + sx?: SxProps + url?: string + groupName: string + headState: HeadState + onLocation: () => void + onCheckDelay: () => void + onHeadState: (val: Partial) => void +} + +export const ProxyGroupTools = memo(function ProxyGroupTools(props: Props) { + const { + sx, + url, + groupName, + headState, + onCheckDelay, + onHeadState, + onLocation, + } = props + + const { + showType, + sortType, + filterText, + textState, + testUrl, + filterMatchCase, + filterMatchWholeWord, + filterUseRegularExpression, + } = headState + + const { t } = useTranslation() + + const { verge } = useVerge() + const defaultLatencyUrl = + verge?.default_latency_test?.trim() || + 'http://cp.cloudflare.com/generate_204' + + useEffect(() => { + delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl) + }, [groupName, testUrl, defaultLatencyUrl, url]) + + 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, + }) + } + /> + + )} + + {textState === 'url' && ( + { + e.preventDefault() + e.stopPropagation() + }} + onChange={(e) => onHeadState({ testUrl: e.target.value })} + sx={{ width: 180, input: { py: 0.65, px: 1 } }} + /> + )} + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + onLocation() + }} + > + + + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + // Remind the user that it is custom test url + if (testUrl?.trim() && textState !== 'filter') { + onHeadState({ textState: 'url' }) + } + onCheckDelay() + }} + > + + + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + onHeadState({ + sortType: ((sortType + 1) % 3) as ProxySortType, + }) + }} + > + {sortType !== 1 && sortType !== 2 && } + {sortType === 1 && } + {sortType === 2 && } + + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + onHeadState({ + textState: textState === 'url' ? null : 'url', + }) + }} + > + {textState === 'url' ? ( + + ) : ( + + )} + + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + onHeadState({ showType: !showType }) + }} + > + {showType ? ( + + ) : ( + + )} + + + { + e.preventDefault() + e.stopPropagation() + if (!headState.open) + // eslint-disable-next-line @eslint-react/dom-no-flush-sync + flushSync(() => onHeadState({ open: true })) + onHeadState({ + textState: textState === 'filter' ? null : 'filter', + filterText: '', + }) + }} + > + {textState === 'filter' ? ( + + ) : ( + + )} + + + ) +}) diff --git a/src/components/proxy/proxy-groups-chain.tsx b/src/components/proxy/proxy-groups-chain.tsx new file mode 100644 index 000000000..498fe3a05 --- /dev/null +++ b/src/components/proxy/proxy-groups-chain.tsx @@ -0,0 +1,492 @@ +import { ExpandMoreRounded } from '@mui/icons-material' +import { + Alert, + Box, + Chip, + IconButton, + Menu, + MenuItem, + Snackbar, + Typography, +} from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { + type Key, + type MouseEvent, + type RefObject, + useCallback, + useEffect, + useMemo, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' + +import { updateProxyChainConfigInRuntime } from '@/services/cmds' + +import { ScrollTopButton } from '../layout/scroll-top-button' + +import { ProxyChain } from './proxy-chain' +import { ProxyRender } from './proxy-render' +import type { HeadState } from './use-head-state' +import type { IRenderItem } from './use-render-list' + +// ---- Types ---- + +interface ProxyChainItem { + id: string + name: string + type?: string + delay?: number +} + +type VirtualListItem = { + key: Key + index: number + start: number + end: number +} + +interface ProxyGroupOption { + name: string + type: string + all?: unknown[] +} + +// ---- Props ---- + +interface ChainRuleHeaderProps { + title: string + selectLabel: string + currentGroup: ProxyGroupOption | null + canSelectGroup: boolean + onMenuOpen: (event: MouseEvent) => void +} + +interface GroupSelectMenuProps { + anchorEl: HTMLElement | null + groups: ProxyGroupOption[] + selectedGroup: string | null + emptyText: string + onClose: () => void + onSelect: (groupName: string) => void +} + +interface ProxyGroupsChainProps { + mode: string + chainConfigData?: string | null + availableGroups: any[] + activeSelectedGroup: string | null + showScrollTop: boolean + + // Virtual list data (from parent's virtualizer) + parentRef: RefObject + totalSize: number + virtualItems: VirtualListItem[] + renderList: IRenderItem[] + activeStickyIndex: number | null + measureElement: (node: Element | null) => void + + // Shared callbacks + onCheckAll: (groupName: string) => void + onHeadState: (groupName: string, patch: Partial) => void + onLocation: (group: any) => void + onGroupSelect: (groupName: string) => void + onScrollToTop: () => void +} + +// ---- Sub-components ---- + +function ChainRuleHeader({ + title, + selectLabel, + currentGroup, + canSelectGroup, + onMenuOpen, +}: ChainRuleHeaderProps) { + return ( + + + + + {title} + + + {currentGroup && ( + + )} + + + {canSelectGroup && ( + + + {selectLabel} + + + + )} + + + ) +} + +function GroupSelectMenu({ + anchorEl, + groups, + selectedGroup, + emptyText, + onClose, + onSelect, +}: GroupSelectMenuProps) { + return ( + + {groups.map((group) => ( + onSelect(group.name)} + selected={selectedGroup === group.name} + sx={{ fontSize: '14px', py: 1 }} + > + + + {group.name} + + + {group.type} · {group.all?.length ?? 0} 节点 + + + + ))} + + {groups.length === 0 && ( + + + {emptyText} + + + )} + + ) +} + +function ProxyVirtualList({ + parentRef, + height, + totalSize, + virtualItems, + renderList, + activeStickyIndex, + isChainMode, + measureElement, + onLocation, + onCheckAll, + onHeadState, + onChangeProxy, +}: { + parentRef: RefObject + height: string + totalSize: number + virtualItems: VirtualListItem[] + renderList: IRenderItem[] + activeStickyIndex: number | null + isChainMode?: boolean + measureElement: (node: Element | null) => void + onLocation: (group: any) => void + onCheckAll: (groupName: string) => void + onHeadState: (groupName: string, patch: Partial) => void + onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void +}) { + const theme = useTheme() + const stickyBackground = + theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)' + + return ( +
+
+ {virtualItems.map((virtualItem) => ( +
+ +
+ ))} +
+
+
+ ) +} + +// ---- Main Chain Component ---- + +export function ProxyGroupsChain(props: ProxyGroupsChainProps) { + const { t } = useTranslation() + const { + mode, + chainConfigData, + availableGroups, + activeSelectedGroup, + showScrollTop, + parentRef, + totalSize, + virtualItems, + renderList, + activeStickyIndex, + measureElement, + onCheckAll, + onHeadState, + onLocation, + onGroupSelect, + onScrollToTop, + } = props + + // Chain-specific state + const [proxyChain, setProxyChain] = useState(() => { + try { + const saved = localStorage.getItem('proxy-chain-items') + if (saved) { + return JSON.parse(saved) + } + } catch { + // ignore + } + return [] + }) + + useEffect(() => { + if (proxyChain.length > 0) { + localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain)) + } else { + localStorage.removeItem('proxy-chain-items') + } + }, [proxyChain]) + + const [ruleMenuAnchor, setRuleMenuAnchor] = useState(null) + const [duplicateWarning, setDuplicateWarning] = useState<{ + open: boolean + message: string + }>({ open: false, message: '' }) + + // Compute current group for rule header + const currentGroup = useMemo(() => { + if (!activeSelectedGroup) return null + return ( + availableGroups.find( + (group: any) => group.name === activeSelectedGroup, + ) ?? null + ) + }, [activeSelectedGroup, availableGroups]) + + // Handlers + const handleGroupMenuOpen = (event: React.MouseEvent) => { + setRuleMenuAnchor(event.currentTarget) + } + + const handleGroupMenuClose = () => { + setRuleMenuAnchor(null) + } + + const handleGroupSelect = (groupName: string) => { + onGroupSelect(groupName) + handleGroupMenuClose() + + if (mode === 'rule') { + updateProxyChainConfigInRuntime(null) + localStorage.removeItem('proxy-chain-group') + localStorage.removeItem('proxy-chain-exit-node') + localStorage.removeItem('proxy-chain-items') + setProxyChain([]) + } + } + + const handleCloseDuplicateWarning = useCallback(() => { + setDuplicateWarning({ open: false, message: '' }) + }, []) + + const handleChangeProxy = useCallback( + (_group: IProxyGroupItem, proxy: IProxyItem) => { + // 使用函数式更新来避免状态延迟问题 + setProxyChain((prev) => { + // 检查是否已经存在相同名称的代理,防止重复添加 + if (prev.some((item) => item.name === proxy.name)) { + const warningMessage = t('proxies.page.chain.duplicateNode') + setDuplicateWarning({ + open: true, + message: warningMessage, + }) + return prev // 返回原来的状态,不做任何更改 + } + + // 安全获取延迟数据,如果没有延迟数据则设为 undefined + const delay = + proxy.history && proxy.history.length > 0 + ? proxy.history[proxy.history.length - 1].delay + : undefined + + const chainItem: ProxyChainItem = { + id: `${proxy.name}_${Date.now()}`, + name: proxy.name, + type: proxy.type, + delay, + } + + return [...prev, chainItem] + }) + }, + [t], + ) + + // Render virtual list for chain mode + const renderProxyList = (height: string) => ( + + ) + + const showRuleHeader = mode === 'rule' && availableGroups.length > 0 + + return ( + <> + + + {showRuleHeader && ( + 0} + onMenuOpen={handleGroupMenuOpen} + /> + )} + + {renderProxyList( + showRuleHeader ? 'calc(100% - 80px)' : 'calc(100% - 14px)', + )} + + + + + + + + + + + {duplicateWarning.message} + + + + + + ) +} diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index b51f64eec..5316fb844 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -1,21 +1,9 @@ -import { ExpandMoreRounded } from '@mui/icons-material' -import { - Alert, - Box, - Chip, - IconButton, - Menu, - MenuItem, - Snackbar, - Typography, -} from '@mui/material' -import { useTheme } from '@mui/material/styles' import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' +import { throttle } from 'lodash-es' import { - type Key, - type MouseEvent, - type RefObject, + lazy, + Suspense, useCallback, useEffect, useLayoutEffect, @@ -23,30 +11,35 @@ import { useRef, useState, } from 'react' -import { useTranslation } from 'react-i18next' -import { useLocation } from 'react-router' import { delayGroup, healthcheckProxyProvider } from 'tauri-plugin-mihomo-api' -import { BaseEmpty } from '@/components/base' +import { + BaseEmpty, + BaseLoading, + StickyVirtualList, + type StickyVirtualListHandle, +} from '@/components/base' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' import { useProxiesData } from '@/providers/app-data-context' -import { calcuProxies, updateProxyChainConfigInRuntime } from '@/services/cmds' +import { calcuProxies } from '@/services/cmds' import delayManager from '@/services/delay' import { useQuery } from '@/services/query-client' import { debugLog } from '@/utils/debug' -import { ScrollTopButton } from '../layout/scroll-top-button' - -import { ProxyChain } from './proxy-chain' import { DEFAULT_HOVER_DELAY, ProxyGroupNavigator, } from './proxy-group-navigator' import { ProxyRender } from './proxy-render' -import type { HeadState } from './use-head-state' import { type IRenderItem, useRenderList } from './use-render-list' +const ProxyGroupsChain = lazy(() => + import('./proxy-groups-chain').then((m) => ({ + default: m.ProxyGroupsChain, + })), +) + function useStableCallback any>(fn: T): T { const ref = useRef(fn) ref.current = fn @@ -59,83 +52,24 @@ interface Props { chainConfigData?: string | null } -interface ProxyChainItem { - id: string - name: string - type?: string - delay?: number -} - -export const ProxyGroups = (props: Props) => { - const { t } = useTranslation() - const { pathname } = useLocation() - const { mode, isChainMode = false, chainConfigData } = props - - // Drive 3s polling on the shared TQ cache; data is read via granular context below - useQuery({ - queryKey: ['getProxies'], - queryFn: calcuProxies, - refetchInterval: 3000, - refetchIntervalInBackground: false, - staleTime: 1500, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }) - - const [proxyChain, setProxyChain] = useState(() => { - try { - const saved = localStorage.getItem('proxy-chain-items') - if (saved) { - return JSON.parse(saved) - } - } catch { - // ignore - } - return [] - }) - const [selectedGroup, setSelectedGroup] = useState(null) - - useEffect(() => { - if (proxyChain.length > 0) { - localStorage.setItem('proxy-chain-items', JSON.stringify(proxyChain)) - } else { - localStorage.removeItem('proxy-chain-items') - } - }, [proxyChain]) - const [ruleMenuAnchor, setRuleMenuAnchor] = useState(null) - const [duplicateWarning, setDuplicateWarning] = useState<{ - open: boolean - message: string - }>({ open: false, message: '' }) - +function useProxyRenderState( + mode: string, + isChainMode: boolean, + activeSelectedGroup: string | null, +) { const { verge } = useVerge() - const { proxies: proxiesData } = useProxiesData() - const groups = proxiesData?.groups - const availableGroups = useMemo(() => { - if (!groups) return [] - // 在链式代理模式下,仅显示支持选择节点的代理组 - return isChainMode - ? groups.filter((g: any) => g.type === 'Selector' || g.type === 'URLTest') - : groups - }, [groups, isChainMode]) - - const defaultRuleGroup = useMemo(() => { - if (isChainMode && mode === 'rule' && availableGroups.length > 0) { - return availableGroups[0].name - } - return null - }, [availableGroups, isChainMode, mode]) - - const activeSelectedGroup = useMemo( - () => selectedGroup ?? defaultRuleGroup, - [selectedGroup, defaultRuleGroup], - ) - const { renderList, onProxies, onHeadState } = useRenderList( mode, isChainMode, activeSelectedGroup, ) + const scrollPositionKey = useMemo( + () => + isChainMode + ? `${mode}:chain:${activeSelectedGroup ?? 'all'}` + : `${mode}:normal`, + [activeSelectedGroup, isChainMode, mode], + ) const getGroupHeadState = useCallback( (groupName: string) => { @@ -147,247 +81,8 @@ export const ProxyGroups = (props: Props) => { [renderList], ) - // 统代理选择 - const { handleProxyGroupChange } = useProxySelection({ - onSuccess: () => { - onProxies() - }, - onError: (error) => { - console.error('代理切换失败', error) - onProxies() - }, - }) - const timeout = verge?.default_latency_timeout || 10000 - const parentRef = useRef(null) - const scrollPositionRef = useRef>({}) - const scrollTopRef = useRef(0) - const showScrollTopRef = useRef(false) - const activeStickyIndexRef = useRef(null) - const restoredScrollKeyRef = useRef(null) - const [showScrollTop, setShowScrollTop] = useState(false) - const scrollPositionKey = useMemo( - () => - isChainMode - ? `${mode}:chain:${activeSelectedGroup ?? 'all'}` - : `${mode}:normal`, - [activeSelectedGroup, isChainMode, mode], - ) - const stickyGroupIndexes = useMemo( - () => - renderList.flatMap((item, index) => - item.type === 0 && !item.group.hidden ? [index] : [], - ), - [renderList], - ) - - const rangeExtractor = useCallback( - (range: Parameters[0]) => { - let activeStickyIndex: number | undefined - for (let i = stickyGroupIndexes.length - 1; i >= 0; i -= 1) { - const index = stickyGroupIndexes[i] - if (index <= range.startIndex) { - activeStickyIndex = index - break - } - } - activeStickyIndexRef.current = activeStickyIndex ?? null - - const indexes = defaultRangeExtractor(range) - return activeStickyIndex == null || indexes.includes(activeStickyIndex) - ? indexes - : [activeStickyIndex, ...indexes] - }, - [stickyGroupIndexes], - ) - - const virtualizer = useVirtualizer({ - count: renderList.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 56, - overscan: 8, - getItemKey: (index) => renderList[index]?.key ?? index, - rangeExtractor, - }) - const virtualItems = virtualizer.getVirtualItems() - const activeStickyIndex = activeStickyIndexRef.current - - // 从 localStorage 恢复滚动位置 - useLayoutEffect(() => { - if (renderList.length === 0) return - const node = parentRef.current - if (!node) return - if ( - restoredScrollKeyRef.current === scrollPositionKey && - node.scrollTop === scrollTopRef.current - ) { - return - } - - try { - const savedPositions = localStorage.getItem('proxy-scroll-positions') - if (savedPositions) { - const positions = JSON.parse(savedPositions) - scrollPositionRef.current = positions - const savedPosition = positions[scrollPositionKey] - - if (savedPosition !== undefined) { - node.scrollTop = savedPosition - scrollTopRef.current = savedPosition - const nextShowScrollTop = savedPosition > 100 - showScrollTopRef.current = nextShowScrollTop - queueMicrotask(() => setShowScrollTop(nextShowScrollTop)) - } - } - } catch (e) { - console.error('Error restoring scroll position:', e) - } - restoredScrollKeyRef.current = scrollPositionKey - }, [pathname, renderList.length, scrollPositionKey]) - - // 改为使用节流函数保存滚动位置 - const saveScrollPosition = useCallback( - (scrollTop: number) => { - try { - scrollPositionRef.current[scrollPositionKey] = scrollTop - localStorage.setItem( - 'proxy-scroll-positions', - JSON.stringify(scrollPositionRef.current), - ) - } catch (e) { - console.error('Error saving scroll position:', e) - } - }, - [scrollPositionKey], - ) - - const saveScrollPositionThrottled = useMemo( - () => throttle(saveScrollPosition, 500), - [saveScrollPosition], - ) - - const handleScroll = useCallback( - (event: Event) => { - const target = event.target as HTMLElement | null - const nextScrollTop = target?.scrollTop ?? 0 - const nextShowScrollTop = nextScrollTop > 100 - scrollTopRef.current = nextScrollTop - - if (showScrollTopRef.current !== nextShowScrollTop) { - showScrollTopRef.current = nextShowScrollTop - setShowScrollTop(nextShowScrollTop) - } - - saveScrollPositionThrottled(nextScrollTop) - }, - [saveScrollPositionThrottled], - ) - - // 添加和清理滚动事件监听器 - useEffect(() => { - const node = parentRef.current - if (!node) return - - const listener = handleScroll as EventListener - const options: AddEventListenerOptions = { passive: true } - - node.addEventListener('scroll', listener, options) - - return () => { - if (restoredScrollKeyRef.current === scrollPositionKey) { - saveScrollPosition(scrollTopRef.current) - } - node.removeEventListener('scroll', listener, options) - } - }, [handleScroll, saveScrollPosition, scrollPositionKey]) - - // 滚动到顶部 - const scrollToTop = useCallback(() => { - parentRef.current?.scrollTo?.({ - top: 0, - behavior: 'smooth', - }) - scrollTopRef.current = 0 - saveScrollPosition(0) - }, [saveScrollPosition]) - - // 关闭重复节点警告 - const handleCloseDuplicateWarning = useCallback(() => { - setDuplicateWarning({ open: false, message: '' }) - }, []) - - const currentGroup = useMemo(() => { - if (!activeSelectedGroup) return null - return ( - availableGroups.find( - (group: any) => group.name === activeSelectedGroup, - ) ?? null - ) - }, [activeSelectedGroup, availableGroups]) - - // 处理代理组选择菜单 - const handleGroupMenuOpen = (event: React.MouseEvent) => { - setRuleMenuAnchor(event.currentTarget) - } - - const handleGroupMenuClose = () => { - setRuleMenuAnchor(null) - } - - const handleGroupSelect = (groupName: string) => { - setSelectedGroup(groupName) - handleGroupMenuClose() - - if (isChainMode && mode === 'rule') { - updateProxyChainConfigInRuntime(null) - localStorage.removeItem('proxy-chain-group') - localStorage.removeItem('proxy-chain-exit-node') - localStorage.removeItem('proxy-chain-items') - setProxyChain([]) - } - } - - const handleChangeProxy = useCallback( - (group: IProxyGroupItem, proxy: IProxyItem) => { - if (isChainMode) { - // 使用函数式更新来避免状态延迟问题 - setProxyChain((prev) => { - // 检查是否已经存在相同名称的代理,防止重复添加 - if (prev.some((item) => item.name === proxy.name)) { - const warningMessage = t('proxies.page.chain.duplicateNode') - setDuplicateWarning({ - open: true, - message: warningMessage, - }) - return prev // 返回原来的状态,不做任何更改 - } - - // 安全获取延迟数据,如果没有延迟数据则设为 undefined - const delay = - proxy.history && proxy.history.length > 0 - ? proxy.history[proxy.history.length - 1].delay - : undefined - - const chainItem: ProxyChainItem = { - id: `${proxy.name}_${Date.now()}`, - name: proxy.name, - type: proxy.type, - delay: delay, - } - - return [...prev, chainItem] - }) - return - } - - if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return - - handleProxyGroupChange(group, proxy) - }, - [handleProxyGroupChange, isChainMode, t], - ) - // 测全部延迟 const handleCheckAll = useStableCallback( useLockFn(async (groupName: string) => { @@ -445,6 +140,306 @@ export const ProxyGroups = (props: Props) => { }), ) + const saveScrollPosition = useCallback( + (scrollTop: number) => { + const scrollPositions = localStorage.getItem('proxy-scroll-positions') + ? JSON.parse(localStorage.getItem('proxy-scroll-positions') ?? '{}') + : {} + scrollPositions[scrollPositionKey] = scrollTop + try { + localStorage.setItem( + 'proxy-scroll-positions', + JSON.stringify(scrollPositions), + ) + } catch (e) { + console.error('Error saving scroll position:', e) + } + }, + [scrollPositionKey], + ) + + const getScrollPosition = useCallback(() => { + try { + const savedPositions = localStorage.getItem('proxy-scroll-positions') + if (savedPositions) { + const positions = JSON.parse(savedPositions) + const savedPosition = positions[scrollPositionKey] + return savedPosition ?? 0 + } + } catch (e) { + console.error('Error restoring scroll position:', e) + } + }, [scrollPositionKey]) + + return { + verge, + renderList, + onProxies, + onHeadState, + handleCheckAll, + saveScrollPosition, + getScrollPosition, + } +} + +function ChainProxyGroups(props: { + mode: string + chainConfigData?: string | null +}) { + const { mode, chainConfigData } = props + const { proxies: proxiesData } = useProxiesData() + const [selectedGroup, setSelectedGroup] = useState(null) + + const availableGroups = useMemo(() => { + const groups = proxiesData?.groups + if (!groups) return [] + return groups.filter( + (group: any) => group.type === 'Selector' || group.type === 'URLTest', + ) + }, [proxiesData?.groups]) + + const defaultRuleGroup = useMemo(() => { + if (mode === 'rule' && availableGroups.length > 0) { + return availableGroups[0].name + } + return null + }, [availableGroups, mode]) + + const activeSelectedGroup = selectedGroup ?? defaultRuleGroup + const { + renderList, + onHeadState, + handleCheckAll, + getScrollPosition, + saveScrollPosition, + } = useProxyRenderState(mode, true, activeSelectedGroup) + + const parentRef = useRef(null) + const scrollTopRef = useRef(0) + const showScrollTopRef = useRef(false) + const activeStickyIndexRef = useRef(null) + const [showScrollTop, setShowScrollTop] = useState(false) + const stickyGroupIndexes = useMemo( + () => + renderList.flatMap((item, index) => + item.type === 0 && !item.group.hidden ? [index] : [], + ), + [renderList], + ) + + const rangeExtractor = useCallback( + (range: Parameters[0]) => { + const activeStickyIndex = [...stickyGroupIndexes] + .reverse() + .find((index) => index <= range.startIndex) + activeStickyIndexRef.current = activeStickyIndex ?? null + + const indexes = defaultRangeExtractor(range) + return activeStickyIndex == null || indexes.includes(activeStickyIndex) + ? indexes + : [activeStickyIndex, ...indexes] + }, + [stickyGroupIndexes], + ) + + const virtualizer = useVirtualizer({ + count: renderList.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 56, + overscan: 15, + getItemKey: (index) => renderList[index]?.key ?? index, + rangeExtractor, + }) + const virtualItems = virtualizer.getVirtualItems() + const activeStickyIndex = activeStickyIndexRef.current + + // 从 localStorage 恢复滚动位置 + useLayoutEffect(() => { + if (renderList.length === 0) return + const node = parentRef.current + if (!node) return + + const savedPosition = getScrollPosition() + if (savedPosition !== undefined) { + node.scrollTop = savedPosition + scrollTopRef.current = savedPosition + const nextShowScrollTop = savedPosition > 100 + showScrollTopRef.current = nextShowScrollTop + queueMicrotask(() => setShowScrollTop(nextShowScrollTop)) + } + }, [renderList.length, getScrollPosition]) + + const saveScrollPositionThrottled = useMemo( + () => throttle(saveScrollPosition, 500), + [saveScrollPosition], + ) + + const handleScroll = useCallback( + (event: Event) => { + const target = event.target as HTMLElement | null + const nextScrollTop = target?.scrollTop ?? 0 + const nextShowScrollTop = nextScrollTop > 100 + scrollTopRef.current = nextScrollTop + + if (showScrollTopRef.current !== nextShowScrollTop) { + showScrollTopRef.current = nextShowScrollTop + setShowScrollTop(nextShowScrollTop) + } + + saveScrollPositionThrottled(nextScrollTop) + }, + [saveScrollPositionThrottled], + ) + + useEffect(() => { + const node = parentRef.current + if (!node) return + + const listener = handleScroll as EventListener + const options: AddEventListenerOptions = { passive: true } + + node.addEventListener('scroll', listener, options) + + return () => { + saveScrollPosition(scrollTopRef.current) + node.removeEventListener('scroll', listener, options) + } + }, [handleScroll, saveScrollPosition]) + + const scrollToTop = useCallback(() => { + parentRef.current?.scrollTo?.({ + top: 0, + behavior: 'smooth', + }) + scrollTopRef.current = 0 + }, []) + + const handleLocation = useStableCallback((group: IProxyGroupItem) => { + if (!group) return + const { name, now } = group + + const index = renderList.findIndex( + (item) => + item.group?.name === name && + ((item.type === 2 && item.proxy?.name === now) || + (item.type === 4 && + item.proxyCol?.some((proxy) => proxy.name === now))), + ) + + if (index >= 0) { + virtualizer.scrollToIndex(index, { + align: 'center', + behavior: 'smooth', + }) + } + }) + + return ( + }> + + + ) +} + +function NormalProxyGroups(props: { mode: string }) { + const { mode } = props + const stickyListRef = useRef(null) + const { + verge, + renderList, + onProxies, + onHeadState, + handleCheckAll, + getScrollPosition, + saveScrollPosition, + } = useProxyRenderState(mode, false, null) + const renderFirstRef = useRef(true) + + // 目前无法使用 StickyVirtualList 的 initialOffset 值设置初始化,具体原因需排查 + // 从 localStorage 恢复滚动位置 + useLayoutEffect(() => { + if (renderList.length === 0) 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 + renderFirstRef.current = false + } + }, [renderList.length, getScrollPosition]) + + const saveScrollPositionThrottled = useMemo( + () => throttle(saveScrollPosition, 500), + [saveScrollPosition], + ) + + const handleScroll = useCallback( + (event: Event) => { + const target = event.target as HTMLElement | null + const nextScrollTop = target?.scrollTop ?? 0 + + saveScrollPositionThrottled(nextScrollTop) + }, + [saveScrollPositionThrottled], + ) + + useEffect(() => { + const node = stickyListRef.current?.getScrollElement() + if (!node) return + + const listener = handleScroll as EventListener + const options: AddEventListenerOptions = { passive: true } + + node.addEventListener('scroll', listener, options) + + return () => { + node.removeEventListener('scroll', listener, options) + } + }, [handleScroll]) + + const { handleProxyGroupChange } = useProxySelection({ + onSuccess: () => { + onProxies() + }, + onError: (error) => { + console.error('代理切换失败', error) + onProxies() + }, + }) + + const handleChangeProxy = useCallback( + (group: IProxyGroupItem, proxy: IProxyItem) => { + if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return + + handleProxyGroupChange(group, proxy) + }, + [handleProxyGroupChange], + ) + // 滚到对应的节点 const handleLocation = useStableCallback((group: IProxyGroupItem) => { if (!group) return @@ -458,7 +453,10 @@ export const ProxyGroups = (props: Props) => { ) if (index >= 0) { - virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' }) + stickyListRef.current?.scrollToIndex(index, { + align: 'center', + behavior: 'smooth', + }) } }) @@ -470,10 +468,13 @@ export const ProxyGroups = (props: Props) => { ) if (index >= 0) { - virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' }) + stickyListRef.current?.scrollToIndex(index, { + align: 'start', + behavior: 'smooth', + }) } }, - [renderList, virtualizer], + [renderList], ) const proxyGroupNames = useMemo(() => { @@ -483,95 +484,76 @@ export const ProxyGroups = (props: Props) => { return Array.from(new Set(names)) }, [renderList]) - const renderProxyList = (height: string) => ( - + // 点击代理组改变展开状态,先滚动到sticky的代理组位置,再收起展开状态 + const handleGroupToggle = useCallback( + async (group: IProxyGroupItem) => { + const index = renderList.findIndex( + (item) => item.type === 0 && item.group.name === group.name, + ) + if (index < 0) return + + if (!stickyListRef.current?.isItemScrolledPastStart(index, 1)) return + + stickyListRef.current.scrollToIndex(index, { + align: 'start', + behavior: 'auto', + }) + + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + }, + [renderList], ) - if (mode === 'direct') { - return - } + const renderGroupItem = useCallback( + (item: IRenderItem, _index: number, stickyed: boolean) => ( + + ), + [ + handleChangeProxy, + handleCheckAll, + onHeadState, + handleLocation, + handleGroupToggle, + ], + ) - if (isChainMode) { - // 获取所有代理组 - const proxyGroups = proxiesData?.groups || [] - const showRuleHeader = mode === 'rule' && proxyGroups.length > 0 - - return ( - <> - - - {showRuleHeader && ( - 0} - onMenuOpen={handleGroupMenuOpen} - /> - )} - - {renderProxyList( - showRuleHeader ? 'calc(100% - 80px)' : 'calc(100% - 14px)', - )} - - - - - - - - - - - {duplicateWarning.message} - - - - - - ) - } + const renderProxyItem = useCallback( + (item: IRenderItem) => ( + + ), + [handleChangeProxy, handleCheckAll, onHeadState, handleLocation], + ) return ( -
+
+ item.type === 0} + getItemKey={(item) => item.key} + estimateGroupItemHeight={76} + estimateItemHeight={64} + renderGroupItem={renderGroupItem} + renderItem={renderProxyItem} + /> + {/* 代理组导航栏 */} {mode === 'rule' && ( { hoverDelay={verge?.hover_jump_navigator_delay ?? DEFAULT_HOVER_DELAY} /> )} - - {renderProxyList('calc(100% - 14px)')} -
) } -type VirtualListItem = { - key: Key - index: number - start: number - end: number -} +export const ProxyGroups = (props: Props) => { + const { mode, isChainMode = false, chainConfigData } = props -interface ProxyVirtualListProps { - parentRef: RefObject - height: string - totalSize: number - virtualItems: VirtualListItem[] - renderList: IRenderItem[] - activeStickyIndex: number | null - indent: boolean - isChainMode?: boolean - measureElement: (node: Element | null) => void - onLocation: (group: IRenderItem['group']) => void - onCheckAll: (groupName: string) => void - onHeadState: (groupName: string, patch: Partial) => void - onChangeProxy: ( - group: IRenderItem['group'], - proxy: IRenderItem['proxy'] & { name: string }, - ) => void -} + // Drive 3s polling on the shared TQ cache; data is read via granular context below + useQuery({ + queryKey: ['getProxies'], + queryFn: calcuProxies, + refetchInterval: 3000, + refetchIntervalInBackground: false, + staleTime: 1500, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }) -interface ProxyGroupOption { - name: string - type: string - all?: unknown[] -} - -interface ChainRuleHeaderProps { - title: string - selectLabel: string - currentGroup: ProxyGroupOption | null - canSelectGroup: boolean - onMenuOpen: (event: MouseEvent) => void -} - -function ChainRuleHeader({ - title, - selectLabel, - currentGroup, - canSelectGroup, - onMenuOpen, -}: ChainRuleHeaderProps) { - return ( - - - - - {title} - - - {currentGroup && ( - - )} - - - {canSelectGroup && ( - - - {selectLabel} - - - - )} - - - ) -} - -interface GroupSelectMenuProps { - anchorEl: HTMLElement | null - groups: ProxyGroupOption[] - selectedGroup: string | null - emptyText: string - onClose: () => void - onSelect: (groupName: string) => void -} - -function GroupSelectMenu({ - anchorEl, - groups, - selectedGroup, - emptyText, - onClose, - onSelect, -}: GroupSelectMenuProps) { - return ( - - {groups.map((group) => ( - onSelect(group.name)} - selected={selectedGroup === group.name} - sx={{ fontSize: '14px', py: 1 }} - > - - - {group.name} - - - {group.type} · {group.all?.length ?? 0} 节点 - - - - ))} - - {groups.length === 0 && ( - - - {emptyText} - - - )} - - ) -} - -function ProxyVirtualList({ - parentRef, - height, - totalSize, - virtualItems, - renderList, - activeStickyIndex, - indent, - isChainMode, - measureElement, - onLocation, - onCheckAll, - onHeadState, - onChangeProxy, -}: ProxyVirtualListProps) { - const theme = useTheme() - const stickyBackground = - theme.palette.mode === 'dark' ? '#1e1f27' : 'var(--background-color)' - - return ( -
-
- {virtualItems.map((virtualItem) => ( -
- -
- ))} -
-
-
- ) -} - -// 替换简单防抖函数为更优的节流函数 -function throttle any>( - func: T, - wait: number, -): (...args: Parameters) => void { - let timer: ReturnType | null = null - let previous = 0 - let lastArgs: Parameters | null = null - - const run = (args: Parameters) => { - previous = Date.now() - timer = null - lastArgs = null - func(...args) + if (mode === 'direct') { + return } - return function (...args: Parameters) { - const now = Date.now() - const remaining = wait - (now - previous) - lastArgs = args - - if (remaining <= 0 || remaining > wait) { - if (timer) { - clearTimeout(timer) - } - run(args) - } else if (!timer) { - timer = setTimeout(() => { - if (lastArgs) { - run(lastArgs) - } - }, remaining) - } + if (isChainMode) { + return } + + return } diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index 175a3102e..d1f91697d 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -11,7 +11,7 @@ import { SortByAlphaRounded, SortRounded, } from '@mui/icons-material' -import { Box, IconButton, TextField, SxProps } from '@mui/material' +import { Box, IconButton, TextField, type SxProps } from '@mui/material' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index e630293a4..5b0992a03 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -20,15 +20,16 @@ import { useIconCache } from '@/hooks/use-icon-cache' import { useVerge } from '@/hooks/use-verge' import { useThemeMode } from '@/services/states' +import { ProxyGroupTools } from './proxy-group-tools' import { ProxyHead } from './proxy-head' import { ProxyItem } from './proxy-item' import { ProxyItemMini } from './proxy-item-mini' -import { HeadState } from './use-head-state' +import type { HeadState } from './use-head-state' import type { IRenderItem } from './use-render-list' interface RenderProps { item: IRenderItem - indent: boolean + stickyed?: boolean isChainMode?: boolean onLocation: (group: IRenderItem['group']) => void onCheckAll: (groupName: string) => void @@ -37,24 +38,26 @@ interface RenderProps { group: IRenderItem['group'], proxy: IRenderItem['proxy'] & { name: string }, ) => void + onGroupToggle?: (group: IRenderItem['group']) => void } export const ProxyRender = memo(function ProxyRender(props: RenderProps) { const { t } = useTranslation() const { - indent, item, + stickyed = false, onLocation, onCheckAll, onHeadState, onChangeProxy, + onGroupToggle, isChainMode: _ = false, } = props const { type, group, headState, proxy, proxyCol } = item const { verge } = useVerge() const enable_group_icon = verge?.enable_group_icon ?? true const mode = useThemeMode() - const isDark = mode === 'light' ? false : true + const isDark = mode === 'dark' const itembackgroundcolor = isDark ? '#282A36' : '#ffffff' const iconCachePath = useIconCache({ icon: group.icon, @@ -72,101 +75,135 @@ export const ProxyRender = memo(function ProxyRender(props: RenderProps) { onChangeProxy(group, proxyItem!)} + onClick={() => onChangeProxy(group, proxyItem)} /> )) }, [type, proxyCol, item.key, group, showType, onChangeProxy]) if (type === 0) { return ( - onHeadState(group.name, { open: !headState?.open })} - > - {enable_group_icon && - group.icon && - group.icon.trim().startsWith('http') && ( +
+ { + if (headState?.open) { + onGroupToggle?.(group) + } + onHeadState?.(group.name, { open: !headState?.open }) + }} + > + {enable_group_icon && group.icon?.trim().startsWith('http') && ( group icon )} - {enable_group_icon && - group.icon && - group.icon.trim().startsWith('data') && ( + {enable_group_icon && group.icon?.trim().startsWith('data') && ( group icon )} - {enable_group_icon && - group.icon && - group.icon.trim().startsWith(' )} - {group.name}} - secondary={ - - - {group.type} - - {group.now} - + {group.name}} + secondary={ + + + {group.type} + + {group.now} + + - - } - slotProps={{ - secondary: { - component: 'div', - sx: { display: 'flex', alignItems: 'center', color: '#ccc' }, - }, - }} - /> - - - - alpha(theme.palette.primary.main, 0.1), - color: (theme) => theme.palette.primary.main, - }} + } + slotProps={{ + secondary: { + component: 'div', + sx: { display: 'flex', alignItems: 'center', color: '#ccc' }, + }, + }} + /> + + 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 ? : } + + +
) } if (type === 1) { return ( diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 8814213a3..da8b1f057 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -428,12 +428,15 @@ export const useRenderList = ( }, ) - ret.push({ - type: 1, - key: `head-${group.name}`, - group, - headState, - }) + // 全局模式下,添加组头 + if (!useRule) { + ret.push({ + type: 1, + key: `head-${group.name}`, + group, + headState, + }) + } if (!proxies.length) { ret.push({ diff --git a/src/pages/proxies.tsx b/src/pages/proxies.tsx index f846aa22c..c16a90e76 100644 --- a/src/pages/proxies.tsx +++ b/src/pages/proxies.tsx @@ -139,7 +139,7 @@ const ProxyPage = () => { return (