mirror of
https://github.com/clash-verge-rev/clash-verge-rev
synced 2025-05-05 04:43:44 +08:00
feat: Enhance proxy groups with Initials navigation and performance optimizations
This commit is contained in:
parent
8e8dd1ec03
commit
6be7a3b94c
@ -118,7 +118,6 @@ impl SpeedRate {
|
|||||||
let icon_text_gap = 10;
|
let icon_text_gap = 10;
|
||||||
let max_text_width: f32 = 510.0;
|
let max_text_width: f32 = 510.0;
|
||||||
let text_area_start = width as i32 + icon_text_gap;
|
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 {
|
for x in text_area_start..image.width() as i32 {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState, useEffect, useCallback } from "react";
|
import { useRef, useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useLockFn } from "ahooks";
|
import { useLockFn } from "ahooks";
|
||||||
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
|
||||||
import {
|
import {
|
||||||
@ -16,6 +16,101 @@ import { ProxyRender } from "./proxy-render";
|
|||||||
import delayManager from "@/services/delay";
|
import delayManager from "@/services/delay";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ScrollTopButton } from "../layout/scroll-top-button";
|
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;
|
||||||
|
}) => (
|
||||||
|
<div className="letter" onClick={() => onClick(name)}>
|
||||||
|
<span>{getFirstChar(name)}</span>
|
||||||
|
<div className="tooltip">{name}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: string;
|
mode: string;
|
||||||
@ -34,7 +129,35 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||||
const scrollPositionRef = useRef<Record<string, number>>({});
|
const scrollPositionRef = useRef<Record<string, number>>({});
|
||||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||||
const scrollerRef = useRef<Element | null>(null);
|
const scrollerRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// 使用useMemo缓存字母索引数据
|
||||||
|
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
|
||||||
|
const letters = new Set<string>();
|
||||||
|
const indexMap: Record<string, number> = {};
|
||||||
|
|
||||||
|
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 恢复滚动位置
|
// 从 localStorage 恢复滚动位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -77,13 +200,13 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
[mode],
|
[mode],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 优化滚动处理函数
|
// 优化滚动处理函数,使用防抖
|
||||||
const handleScroll = useCallback(
|
const handleScroll = useCallback(
|
||||||
(e: any) => {
|
debounce((e: any) => {
|
||||||
const scrollTop = e.target.scrollTop;
|
const scrollTop = e.target.scrollTop;
|
||||||
setShowScrollTop(scrollTop > 100);
|
setShowScrollTop(scrollTop > 100);
|
||||||
saveScrollPosition(scrollTop);
|
saveScrollPosition(scrollTop);
|
||||||
},
|
}, 16),
|
||||||
[saveScrollPosition],
|
[saveScrollPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -109,6 +232,21 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
saveScrollPosition(0);
|
saveScrollPosition(0);
|
||||||
}, [saveScrollPosition]);
|
}, [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(
|
const handleChangeProxy = useLockFn(
|
||||||
async (group: IProxyGroupItem, proxy: IProxyItem) => {
|
async (group: IProxyGroupItem, proxy: IProxyItem) => {
|
||||||
@ -226,6 +364,29 @@ export const ProxyGroups = (props: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
|
||||||
|
|
||||||
|
<AlphabetSelector>
|
||||||
|
{groupFirstLetters.map((name) => (
|
||||||
|
<LetterItem
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
onClick={handleLetterClick}
|
||||||
|
getFirstChar={getFirstChar}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AlphabetSelector>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 简单的防抖函数
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number,
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState, useCallback } from "react";
|
||||||
import { getProxies } from "@/services/api";
|
import { getProxies } from "@/services/api";
|
||||||
import { useVerge } from "@/hooks/use-verge";
|
import { useVerge } from "@/hooks/use-verge";
|
||||||
import { filterSort } from "./use-filter-sort";
|
import { filterSort } from "./use-filter-sort";
|
||||||
@ -21,34 +21,148 @@ export interface IRenderItem {
|
|||||||
headState?: HeadState;
|
headState?: HeadState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProxiesData {
|
||||||
|
groups: IProxyGroupItem[];
|
||||||
|
global?: IProxyGroupItem;
|
||||||
|
proxies: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存计算结果
|
||||||
|
const groupCache = new WeakMap<ProxiesData, Map<string, IProxyGroupItem>>();
|
||||||
|
// 用于追踪缓存的key
|
||||||
|
const cacheKeys = new Set<ProxiesData>();
|
||||||
|
|
||||||
export const useRenderList = (mode: string) => {
|
export const useRenderList = (mode: string) => {
|
||||||
|
// 添加用户交互标记
|
||||||
|
const isUserInteracting = useRef(false);
|
||||||
|
const interactionTimer = useRef<number | null>(null);
|
||||||
|
// 添加上一次有效的数据缓存
|
||||||
|
const [lastValidData, setLastValidData] = useState<ProxiesData | null>(null);
|
||||||
|
// 添加刷新锁
|
||||||
|
const refreshLock = useRef(false);
|
||||||
|
const lastRenderList = useRef<IRenderItem[]>([]);
|
||||||
|
|
||||||
|
// 组件卸载时清理
|
||||||
|
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(
|
const { data: proxiesData, mutate: mutateProxies } = useSWR(
|
||||||
"getProxies",
|
"getProxies",
|
||||||
getProxies,
|
fetchProxies,
|
||||||
{
|
{
|
||||||
refreshInterval: 2000,
|
refreshInterval: isUserInteracting.current ? 0 : 2000,
|
||||||
dedupingInterval: 1000,
|
dedupingInterval: 1000,
|
||||||
revalidateOnFocus: false,
|
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 { verge } = useVerge();
|
||||||
const { width } = useWindowWidth();
|
const { width } = useWindowWidth();
|
||||||
|
|
||||||
let col = Math.floor(verge?.proxy_layout_column || 6);
|
const col = useMemo(() => {
|
||||||
|
const baseCol = Math.floor(verge?.proxy_layout_column || 6);
|
||||||
// 自适应
|
if (baseCol >= 6 || baseCol <= 0) {
|
||||||
if (col >= 6 || col <= 0) {
|
if (width > 1450) return 4;
|
||||||
if (width > 1450) col = 4;
|
if (width > 1024) return 3;
|
||||||
else if (width > 1024) col = 3;
|
if (width > 900) return 2;
|
||||||
else if (width > 900) col = 2;
|
if (width >= 600) return 2;
|
||||||
else if (width >= 600) col = 2;
|
return 1;
|
||||||
else col = 1;
|
}
|
||||||
}
|
return baseCol;
|
||||||
|
}, [verge?.proxy_layout_column, width]);
|
||||||
|
|
||||||
const [headStates, setHeadState] = useHeadStateNew();
|
const [headStates, setHeadState] = useHeadStateNew();
|
||||||
|
|
||||||
// make sure that fetch the proxies successfully
|
// 优化初始数据加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!proxiesData) return;
|
if (!proxiesData) return;
|
||||||
const { groups, proxies } = proxiesData;
|
const { groups, proxies } = proxiesData;
|
||||||
@ -57,21 +171,23 @@ export const useRenderList = (mode: string) => {
|
|||||||
(mode === "rule" && !groups.length) ||
|
(mode === "rule" && !groups.length) ||
|
||||||
(mode === "global" && proxies.length < 2)
|
(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 useRule = mode === "rule" || mode === "script";
|
||||||
const renderGroups =
|
const renderGroups =
|
||||||
(useRule && proxiesData.groups.length
|
(useRule && currentData.groups.length
|
||||||
? proxiesData.groups
|
? currentData.groups
|
||||||
: [proxiesData.global!]) || [];
|
: [currentData.global!]) || [];
|
||||||
|
|
||||||
const retList = renderGroups.flatMap((group) => {
|
const newList = renderGroups.flatMap((group: IProxyGroupItem) => {
|
||||||
const headState = headStates[group.name] || DEFAULT_STATE;
|
const headState = headStates[group.name] || DEFAULT_STATE;
|
||||||
const ret: IRenderItem[] = [
|
const ret: IRenderItem[] = [
|
||||||
{ type: 0, key: group.name, group, headState },
|
{ type: 0, key: group.name, group, headState },
|
||||||
@ -89,9 +205,9 @@ export const useRenderList = (mode: string) => {
|
|||||||
|
|
||||||
if (!proxies.length) {
|
if (!proxies.length) {
|
||||||
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
|
ret.push({ type: 3, key: `empty-${group.name}`, group, headState });
|
||||||
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 支持多列布局
|
|
||||||
if (col > 1) {
|
if (col > 1) {
|
||||||
return ret.concat(
|
return ret.concat(
|
||||||
groupList(proxies, col).map((proxyCol) => ({
|
groupList(proxies, col).map((proxyCol) => ({
|
||||||
@ -118,13 +234,17 @@ export const useRenderList = (mode: string) => {
|
|||||||
return ret;
|
return ret;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!useRule) return retList.slice(1);
|
const filteredList = !useRule
|
||||||
return retList.filter((item) => item.group.hidden === false);
|
? newList.slice(1)
|
||||||
}, [headStates, proxiesData, mode, col]);
|
: newList.filter((item) => !item.group.hidden);
|
||||||
|
|
||||||
|
lastRenderList.current = filteredList;
|
||||||
|
return filteredList;
|
||||||
|
}, [headStates, proxiesData, lastValidData, mode, col]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
renderList,
|
renderList,
|
||||||
onProxies: mutateProxies,
|
onProxies: wrappedMutateProxies,
|
||||||
onHeadState: setHeadState,
|
onHeadState: setHeadState,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user