diff --git a/src-tauri/src/core/tray/speed_rate.rs b/src-tauri/src/core/tray/speed_rate.rs index 2d8f1a58..8bb9a4b7 100644 --- a/src-tauri/src/core/tray/speed_rate.rs +++ b/src-tauri/src/core/tray/speed_rate.rs @@ -118,7 +118,6 @@ impl SpeedRate { let icon_text_gap = 10; let max_text_width: f32 = 510.0; let text_area_start = width as i32 + icon_text_gap; - let text_area_width = max_text_width.ceil() as u32; // 用透明色清除文字区域 for x in text_area_start..image.width() as i32 { diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index 8c4ac3ae..15741f96 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, useEffect, useCallback } from "react"; +import { useRef, useState, useEffect, useCallback, useMemo } from "react"; import { useLockFn } from "ahooks"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { @@ -16,6 +16,101 @@ 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"; + +// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式 +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: "8px 4px", + willChange: "transform", // 优化动画性能 + "& .letter": { + padding: "2px 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.15s cubic-bezier(0.34, 1.56, 0.64, 1)", // 稍微加快动画速度 + transform: "scale(1) translateZ(0)", // 开启GPU加速 + backfaceVisibility: "hidden", // 防止闪烁 + borderRadius: "6px", + "&:hover": { + color: theme.palette.primary.main, + transform: "scale(1.2) translateZ(0)", + backgroundColor: theme.palette.action.hover, + "& .tooltip": { + opacity: 1, + transform: "translateX(0) translateZ(0)", + visibility: "visible", + }, + }, + "&:hover ~ .letter": { + transform: "translateY(2px) translateZ(0)", + }, + }, + "& .tooltip": { + position: "absolute", + right: "calc(100% + 8px)", + background: theme.palette.background.paper, + padding: "4px 8px", + borderRadius: "6px", + boxShadow: theme.shadows[3], + whiteSpace: "nowrap", + opacity: 0, + visibility: "hidden", + transform: "translateX(4px) translateZ(0)", + transition: "all 0.15s cubic-bezier(0.34, 1.56, 0.64, 1)", + fontSize: "12px", + color: theme.palette.text.primary, + pointerEvents: "none", + backfaceVisibility: "hidden", + "&::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; + }) => ( +
onClick(name)}> + {getFirstChar(name)} +
{name}
+
+ ), +); interface Props { mode: string; @@ -34,7 +129,35 @@ export const ProxyGroups = (props: Props) => { const virtuosoRef = useRef(null); const scrollPositionRef = useRef>({}); const [showScrollTop, setShowScrollTop] = useState(false); - const scrollerRef = useRef(null); + const scrollerRef = useRef(null); + + // 使用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(() => { @@ -77,13 +200,13 @@ export const ProxyGroups = (props: Props) => { [mode], ); - // 优化滚动处理函数 + // 优化滚动处理函数,使用防抖 const handleScroll = useCallback( - (e: any) => { + debounce((e: any) => { const scrollTop = e.target.scrollTop; setShowScrollTop(scrollTop > 100); saveScrollPosition(scrollTop); - }, + }, 16), [saveScrollPosition], ); @@ -109,6 +232,21 @@ export const ProxyGroups = (props: Props) => { 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) => { @@ -226,6 +364,29 @@ export const ProxyGroups = (props: Props) => { )} /> + + + {groupFirstLetters.map((name) => ( + + ))} + ); }; + +// 简单的防抖函数 +function debounce any>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 3aafe574..8cd3f093 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -1,5 +1,5 @@ import useSWR from "swr"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { getProxies } from "@/services/api"; import { useVerge } from "@/hooks/use-verge"; import { filterSort } from "./use-filter-sort"; @@ -21,34 +21,148 @@ export interface IRenderItem { headState?: HeadState; } +interface ProxiesData { + groups: IProxyGroupItem[]; + global?: IProxyGroupItem; + proxies: any[]; +} + +// 缓存计算结果 +const groupCache = new WeakMap>(); +// 用于追踪缓存的key +const cacheKeys = new Set(); + export const useRenderList = (mode: string) => { + // 添加用户交互标记 + const isUserInteracting = useRef(false); + const interactionTimer = useRef(null); + // 添加上一次有效的数据缓存 + const [lastValidData, setLastValidData] = useState(null); + // 添加刷新锁 + const refreshLock = useRef(false); + const lastRenderList = useRef([]); + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (interactionTimer.current) { + clearTimeout(interactionTimer.current); + } + refreshLock.current = false; + isUserInteracting.current = false; + // 清理 WeakMap 缓存 + cacheKeys.forEach((key) => { + groupCache.delete(key); + }); + cacheKeys.clear(); + }; + }, []); + + // 优化数据获取函数 + const fetchProxies = useCallback(async () => { + try { + if (isUserInteracting.current || refreshLock.current) { + return lastValidData; + } + const data = await getProxies(); + + // 预处理和缓存组数据 + if (data && !groupCache.has(data)) { + const groupMap = new Map(); + data.groups.forEach((group) => { + groupMap.set(group.name, group); + }); + groupCache.set(data, groupMap); + cacheKeys.add(data); + } + + setLastValidData(data); + return data; + } catch (error) { + if (lastValidData) return lastValidData; + throw error; + } + }, [lastValidData]); + const { data: proxiesData, mutate: mutateProxies } = useSWR( "getProxies", - getProxies, + fetchProxies, { - refreshInterval: 2000, + refreshInterval: isUserInteracting.current ? 0 : 2000, dedupingInterval: 1000, revalidateOnFocus: false, + keepPreviousData: true, + onSuccess: (data) => { + if (!data || isUserInteracting.current) return; + + if (proxiesData) { + try { + const groupMap = groupCache.get(proxiesData); + if (!groupMap) return; + + const needUpdate = data.groups.some((group: IProxyGroupItem) => { + const oldGroup = groupMap.get(group.name); + return !oldGroup || oldGroup.now !== group.now; + }); + + if (!needUpdate) return; + } catch (e) { + console.error("Data comparison error:", e); + return; + } + } + }, }, ); + // 优化mutateProxies包装函数 + const wrappedMutateProxies = useCallback(async () => { + refreshLock.current = true; + isUserInteracting.current = true; + + if (interactionTimer.current) { + clearTimeout(interactionTimer.current); + } + + try { + if (!lastValidData && proxiesData) { + setLastValidData(proxiesData); + } + return await mutateProxies(); + } finally { + interactionTimer.current = window.setTimeout(() => { + isUserInteracting.current = false; + refreshLock.current = false; + interactionTimer.current = null; + }, 2000); + } + }, [proxiesData, lastValidData, mutateProxies]); + + // 确保初始数据加载后更新lastValidData + useEffect(() => { + if (proxiesData && !lastValidData) { + setLastValidData(proxiesData); + } + }, [proxiesData]); + const { verge } = useVerge(); const { width } = useWindowWidth(); - let col = Math.floor(verge?.proxy_layout_column || 6); - - // 自适应 - if (col >= 6 || col <= 0) { - if (width > 1450) col = 4; - else if (width > 1024) col = 3; - else if (width > 900) col = 2; - else if (width >= 600) col = 2; - else col = 1; - } + const col = useMemo(() => { + const baseCol = Math.floor(verge?.proxy_layout_column || 6); + if (baseCol >= 6 || baseCol <= 0) { + if (width > 1450) return 4; + if (width > 1024) return 3; + if (width > 900) return 2; + if (width >= 600) return 2; + return 1; + } + return baseCol; + }, [verge?.proxy_layout_column, width]); const [headStates, setHeadState] = useHeadStateNew(); - // make sure that fetch the proxies successfully + // 优化初始数据加载 useEffect(() => { if (!proxiesData) return; const { groups, proxies } = proxiesData; @@ -57,21 +171,23 @@ export const useRenderList = (mode: string) => { (mode === "rule" && !groups.length) || (mode === "global" && proxies.length < 2) ) { - setTimeout(() => mutateProxies(), 500); + const timer = setTimeout(() => mutateProxies(), 500); + return () => clearTimeout(timer); } - }, [proxiesData, mode]); + }, [proxiesData, mode, mutateProxies]); - const renderList: IRenderItem[] = useMemo(() => { - if (!proxiesData) return []; + // 优化渲染列表计算 + const renderList = useMemo(() => { + const currentData = proxiesData || lastValidData; + if (!currentData) return lastRenderList.current; - // global 和 direct 使用展开的样式 const useRule = mode === "rule" || mode === "script"; const renderGroups = - (useRule && proxiesData.groups.length - ? proxiesData.groups - : [proxiesData.global!]) || []; + (useRule && currentData.groups.length + ? currentData.groups + : [currentData.global!]) || []; - const retList = renderGroups.flatMap((group) => { + const newList = renderGroups.flatMap((group: IProxyGroupItem) => { const headState = headStates[group.name] || DEFAULT_STATE; const ret: IRenderItem[] = [ { type: 0, key: group.name, group, headState }, @@ -89,9 +205,9 @@ export const useRenderList = (mode: string) => { if (!proxies.length) { ret.push({ type: 3, key: `empty-${group.name}`, group, headState }); + return ret; } - // 支持多列布局 if (col > 1) { return ret.concat( groupList(proxies, col).map((proxyCol) => ({ @@ -118,13 +234,17 @@ export const useRenderList = (mode: string) => { return ret; }); - if (!useRule) return retList.slice(1); - return retList.filter((item) => item.group.hidden === false); - }, [headStates, proxiesData, mode, col]); + const filteredList = !useRule + ? newList.slice(1) + : newList.filter((item) => !item.group.hidden); + + lastRenderList.current = filteredList; + return filteredList; + }, [headStates, proxiesData, lastValidData, mode, col]); return { renderList, - onProxies: mutateProxies, + onProxies: wrappedMutateProxies, onHeadState: setHeadState, }; };