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 { useQuery } from '@tanstack/react-query' import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual' import { useLockFn } from 'ahooks' import { type Key, type MouseEvent, type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, 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 { 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 delayManager from '@/services/delay' 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' function useStableCallback any>(fn: T): T { const ref = useRef(fn) ref.current = fn return useCallback((...args: Parameters) => ref.current(...args), []) as T } interface Props { mode: string isChainMode?: boolean 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: '' }) const { verge } = useVerge() const { proxies: proxiesData } = useProxiesData() const groups = proxiesData?.groups const availableGroups = useMemo(() => { if (!groups) return [] // 在链式代理模式下,仅显示支持选择节点的 Selector 代理组 return isChainMode ? groups.filter((g: any) => g.type === 'Selector') : 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 getGroupHeadState = useCallback( (groupName: string) => { const headItem = renderList.find( (item) => item.type === 1 && item.group?.name === groupName, ) return headItem?.headState }, [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]) => { 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 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) => { debugLog(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`) const proxies = renderList .filter( (e) => e.group?.name === groupName && (e.type === 2 || e.type === 4), ) .flatMap((e) => e.proxyCol || e.proxy!) .filter(Boolean) debugLog(`[ProxyGroups] 找到代理数量: ${proxies.length}`) const providers = new Set( proxies.map((p) => p!.provider!).filter(Boolean), ) if (providers.size) { debugLog(`[ProxyGroups] 发现提供者,数量: ${providers.size}`) Promise.allSettled( [...providers].map((p) => healthcheckProxyProvider(p)), ).then(() => { debugLog(`[ProxyGroups] 提供者健康检查完成`) onProxies() }) } const names = proxies.filter((p) => !p!.provider).map((p) => p!.name) debugLog(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`) const url = delayManager.getUrl(groupName) debugLog(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`) try { await Promise.race([ delayManager.checkListDelay(names, groupName, timeout), delayGroup(groupName, url, timeout).then((result) => { debugLog( `[ProxyGroups] getGroupProxyDelays返回结果数量:`, Object.keys(result || {}).length, ) }), // 查询group delays 将清除fixed(不关注调用结果) ]) debugLog(`[ProxyGroups] 延迟测试完成,组: ${groupName}`) } catch (error) { console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { const headState = getGroupHeadState(groupName) if (headState?.sortType === 1) { onHeadState(groupName, { sortType: headState.sortType }) } onProxies() } }), ) // 滚到对应的节点 const handleLocation = useStableCallback((group: IProxyGroupItem) => { if (!group) return const { name, now } = group const index = renderList.findIndex( (e) => e.group?.name === name && ((e.type === 2 && e.proxy?.name === now) || (e.type === 4 && e.proxyCol?.some((p) => p.name === now))), ) if (index >= 0) { virtualizer.scrollToIndex(index, { align: 'center', behavior: 'smooth' }) } }) // 定位到指定的代理组 const handleGroupLocationByName = useCallback( (groupName: string) => { const index = renderList.findIndex( (item) => item.type === 0 && item.group?.name === groupName, ) if (index >= 0) { virtualizer.scrollToIndex(index, { align: 'start', behavior: 'smooth' }) } }, [renderList, virtualizer], ) const proxyGroupNames = useMemo(() => { const names = renderList .filter((item) => item.type === 0 && item.group?.name) .map((item) => item.group!.name) return Array.from(new Set(names)) }, [renderList]) const renderProxyList = (height: string) => ( ) if (mode === 'direct') { return } 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} ) } return (
{/* 代理组导航栏 */} {mode === 'rule' && ( )} {renderProxyList('calc(100% - 14px)')}
) } type VirtualListItem = { key: Key index: number start: number end: number } 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 } 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) } 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) } } }