import { useRef, useState, useEffect, useCallback, useMemo } from "react"; import { useLockFn } from "ahooks"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { getConnections, providerHealthCheck, updateProxy, deleteConnection, getGroupProxyDelays, } from "@/services/api"; import { useProfiles } from "@/hooks/use-profiles"; import { useVerge } from "@/hooks/use-verge"; import { BaseEmpty } from "../base"; import { useRenderList } from "./use-render-list"; import { ProxyRender } from "./proxy-render"; import delayManager from "@/services/delay"; import { useTranslation } from "react-i18next"; import { ScrollTopButton } from "../layout/scroll-top-button"; import { Box, styled } from "@mui/material"; import { memo } from "react"; import { createPortal } from "react-dom"; // 将选择器组件抽离出来,避免主组件重渲染时重复创建样式 const AlphabetSelector = styled(Box)(({ theme }) => ({ position: "fixed", right: 4, top: "50%", transform: "translateY(-50%)", display: "flex", flexDirection: "column", background: "transparent", zIndex: 1000, gap: "2px", // padding: "4px 2px", willChange: "transform", "&:hover": { background: theme.palette.background.paper, boxShadow: theme.shadows[2], borderRadius: "8px", }, "& .scroll-container": { overflow: "hidden", maxHeight: "inherit", }, "& .letter-container": { display: "flex", flexDirection: "column", gap: "2px", transition: "transform 0.2s ease", }, "& .letter": { padding: "1px 4px", fontSize: "12px", cursor: "pointer", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif", color: theme.palette.text.secondary, position: "relative", width: "1.5em", height: "1.5em", display: "flex", alignItems: "center", justifyContent: "center", transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)", transform: "scale(1) translateZ(0)", backfaceVisibility: "hidden", borderRadius: "6px", "&:hover": { color: theme.palette.primary.main, transform: "scale(1.4) translateZ(0)", backgroundColor: theme.palette.action.hover, }, }, })); // 创建一个单独的 Tooltip 组件 const Tooltip = styled("div")(({ theme }) => ({ position: "fixed", background: theme.palette.background.paper, padding: "4px 8px", borderRadius: "6px", boxShadow: theme.shadows[3], whiteSpace: "nowrap", fontSize: "16px", color: theme.palette.text.primary, pointerEvents: "none", "&::after": { content: '""', position: "absolute", right: "-4px", top: "50%", transform: "translateY(-50%)", width: 0, height: 0, borderTop: "4px solid transparent", borderBottom: "4px solid transparent", borderLeft: `4px solid ${theme.palette.background.paper}`, }, })); // 抽离字母选择器子组件 const LetterItem = memo( ({ name, onClick, getFirstChar, }: { name: string; onClick: (name: string) => void; getFirstChar: (str: string) => string; }) => { const [showTooltip, setShowTooltip] = useState(false); const letterRef = useRef(null); const [tooltipPosition, setTooltipPosition] = useState({ top: 0, right: 0, }); const hoverTimeoutRef = useRef>(); const updateTooltipPosition = useCallback(() => { if (!letterRef.current) return; const rect = letterRef.current.getBoundingClientRect(); setTooltipPosition({ top: rect.top + rect.height / 2, right: window.innerWidth - rect.left + 8, }); }, []); useEffect(() => { if (showTooltip) { updateTooltipPosition(); } }, [showTooltip, updateTooltipPosition]); const handleMouseEnter = useCallback(() => { setShowTooltip(true); // 添加 200ms 的延迟,避免鼠标快速划过时触发滚动 hoverTimeoutRef.current = setTimeout(() => { onClick(name); }, 100); }, [name, onClick]); const handleMouseLeave = useCallback(() => { setShowTooltip(false); if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }, []); useEffect(() => { return () => { if (hoverTimeoutRef.current) { clearTimeout(hoverTimeoutRef.current); } }; }, []); return ( <>
onClick(name)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} > {getFirstChar(name)}
{showTooltip && createPortal( {name} , document.body, )} ); }, ); interface Props { mode: string; } export const ProxyGroups = (props: Props) => { const { t } = useTranslation(); const { mode } = props; const { renderList, onProxies, onHeadState } = useRenderList(mode); const { verge } = useVerge(); const { current, patchCurrent } = useProfiles(); const timeout = verge?.default_latency_timeout || 10000; const virtuosoRef = useRef(null); const scrollPositionRef = useRef>({}); const [showScrollTop, setShowScrollTop] = useState(false); const scrollerRef = useRef(null); const letterContainerRef = useRef(null); const alphabetSelectorRef = useRef(null); const [maxHeight, setMaxHeight] = useState("auto"); // 使用useMemo缓存字母索引数据 const { groupFirstLetters, letterIndexMap } = useMemo(() => { const letters = new Set(); const indexMap: Record = {}; renderList.forEach((item, index) => { if (item.type === 0) { const fullName = item.group.name; letters.add(fullName); if (!(fullName in indexMap)) { indexMap[fullName] = index; } } }); return { groupFirstLetters: Array.from(letters), letterIndexMap: indexMap, }; }, [renderList]); // 缓存getFirstChar函数 const getFirstChar = useCallback((str: string) => { const regex = /\p{Extended_Pictographic}|\p{L}|\p{N}|./u; const match = str.match(regex); return match ? match[0] : str.charAt(0); }, []); // 从 localStorage 恢复滚动位置 useEffect(() => { if (renderList.length === 0) return; try { const savedPositions = localStorage.getItem("proxy-scroll-positions"); if (savedPositions) { const positions = JSON.parse(savedPositions); scrollPositionRef.current = positions; const savedPosition = positions[mode]; if (savedPosition !== undefined) { setTimeout(() => { virtuosoRef.current?.scrollTo({ top: savedPosition, behavior: "auto", }); }, 100); } } } catch (e) { console.error("Error restoring scroll position:", e); } }, [mode, renderList]); // 使用防抖保存滚动位置 const saveScrollPosition = useCallback( (scrollTop: number) => { try { scrollPositionRef.current[mode] = scrollTop; localStorage.setItem( "proxy-scroll-positions", JSON.stringify(scrollPositionRef.current), ); } catch (e) { console.error("Error saving scroll position:", e); } }, [mode], ); // 优化滚动处理函数,使用防抖 const handleScroll = useCallback( debounce((e: any) => { const scrollTop = e.target.scrollTop; setShowScrollTop(scrollTop > 100); saveScrollPosition(scrollTop); }, 16), [saveScrollPosition], ); // 添加和清理滚动事件监听器 useEffect(() => { const currentScroller = scrollerRef.current; if (currentScroller) { currentScroller.addEventListener("scroll", handleScroll, { passive: true, }); return () => { currentScroller.removeEventListener("scroll", handleScroll); }; } }, [handleScroll]); // 滚动到顶部 const scrollToTop = useCallback(() => { virtuosoRef.current?.scrollTo?.({ top: 0, behavior: "smooth", }); saveScrollPosition(0); }, [saveScrollPosition]); // 处理字母点击,使用useCallback const handleLetterClick = useCallback( (name: string) => { const index = letterIndexMap[name]; if (index !== undefined) { virtuosoRef.current?.scrollToIndex({ index, align: "start", behavior: "smooth", }); } }, [letterIndexMap], ); // 切换分组的节点代理 const handleChangeProxy = useLockFn( async (group: IProxyGroupItem, proxy: IProxyItem) => { if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return; const { name, now } = group; await updateProxy(name, proxy.name); onProxies(); // 断开连接 if (verge?.auto_close_connection) { getConnections().then(({ connections }) => { connections.forEach((conn) => { if (conn.chains.includes(now!)) { deleteConnection(conn.id); } }); }); } // 保存到selected中 if (!current) return; if (!current.selected) current.selected = []; const index = current.selected.findIndex( (item) => item.name === group.name, ); if (index < 0) { current.selected.push({ name, now: proxy.name }); } else { current.selected[index] = { name, now: proxy.name }; } await patchCurrent({ selected: current.selected }); }, ); // 测全部延迟 const handleCheckAll = useLockFn(async (groupName: string) => { const proxies = renderList .filter( (e) => e.group?.name === groupName && (e.type === 2 || e.type === 4), ) .flatMap((e) => e.proxyCol || e.proxy!) .filter(Boolean); const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean)); if (providers.size) { Promise.allSettled( [...providers].map((p) => providerHealthCheck(p)), ).then(() => onProxies()); } const names = proxies.filter((p) => !p!.provider).map((p) => p!.name); await Promise.race([ delayManager.checkListDelay(names, groupName, timeout), getGroupProxyDelays(groupName, delayManager.getUrl(groupName), timeout), // 查询group delays 将清除fixed(不关注调用结果) ]); onProxies(); }); // 滚到对应的节点 const handleLocation = (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) { virtuosoRef.current?.scrollToIndex?.({ index, align: "center", behavior: "smooth", }); } }; // 添加滚轮事件处理函数 const handleWheel = useCallback((e: WheelEvent) => { e.preventDefault(); if (!letterContainerRef.current) return; const container = letterContainerRef.current; const scrollAmount = e.deltaY; const currentTransform = new WebKitCSSMatrix(container.style.transform); const currentY = currentTransform.m42 || 0; const containerHeight = container.getBoundingClientRect().height; const parentHeight = container.parentElement?.getBoundingClientRect().height || 0; const maxScroll = Math.max(0, containerHeight - parentHeight); let newY = currentY - scrollAmount; newY = Math.min(0, Math.max(-maxScroll, newY)); container.style.transform = `translateY(${newY}px)`; }, []); // 添加和移除滚轮事件监听 useEffect(() => { const container = letterContainerRef.current?.parentElement; if (container) { container.addEventListener("wheel", handleWheel, { passive: false }); return () => { container.removeEventListener("wheel", handleWheel); }; } }, [handleWheel]); // 添加窗口大小变化监听和最大高度计算 const updateMaxHeight = useCallback(() => { if (!alphabetSelectorRef.current) return; const windowHeight = window.innerHeight; const bottomMargin = 60; // 底部边距 const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍 const availableHeight = windowHeight - (topMargin + bottomMargin); // 调整选择器的位置,使其偏下 const offsetPercentage = (((topMargin - bottomMargin) / windowHeight) * 100) / 2; alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`; setMaxHeight(`${availableHeight}px`); }, []); // 监听窗口大小变化 useEffect(() => { updateMaxHeight(); window.addEventListener("resize", updateMaxHeight); return () => { window.removeEventListener("resize", updateMaxHeight); }; }, [updateMaxHeight]); if (mode === "direct") { return ; } return (
{ scrollerRef.current = ref as Element; }} components={{ Footer: () =>
, }} itemContent={(index) => ( )} />
{groupFirstLetters.map((name) => ( ))}
); }; // 简单的防抖函数 function debounce any>( func: T, wait: number, ): (...args: Parameters) => void { let timeout: ReturnType | null = null; return (...args: Parameters) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }