diff --git a/src/components/proxy/proxy-chain.tsx b/src/components/proxy/proxy-chain.tsx index 651fcbc9..ea19e931 100644 --- a/src/components/proxy/proxy-chain.tsx +++ b/src/components/proxy/proxy-chain.tsx @@ -31,7 +31,7 @@ import { Typography, useTheme, } from "@mui/material"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { @@ -196,9 +196,10 @@ export const ProxyChain = ({ const theme = useTheme(); const { t } = useTranslation(); const { proxies } = useAppData(); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [isConnecting, setIsConnecting] = useState(false); - const [isConnected, setIsConnected] = useState(false); + const markUnsavedChanges = useCallback(() => { + onMarkUnsavedChanges?.(); + }, [onMarkUnsavedChanges]); // 获取当前代理信息以检查连接状态 const { data: currentProxies, mutate: mutateProxies } = useSWR( @@ -211,52 +212,26 @@ export const ProxyChain = ({ }, ); - // 检查连接状态 - useEffect(() => { + const isConnected = useMemo(() => { if (!currentProxies || proxyChain.length < 2) { - setIsConnected(false); - return; + return false; } - // 获取用户配置的最后一个节点 const lastNode = proxyChain[proxyChain.length - 1]; - // 根据模式确定要检查的代理组和当前选中的代理 if (mode === "global") { - // 全局模式:检查 global 对象 - if (!currentProxies.global || !currentProxies.global.now) { - setIsConnected(false); - return; - } - - // 检查当前选中的代理是否是配置的最后一个节点 - if (currentProxies.global.now === lastNode.name) { - setIsConnected(true); - } else { - setIsConnected(false); - } - } else { - // 规则模式:检查指定的代理组 - if (!selectedGroup) { - setIsConnected(false); - return; - } - - const proxyChainGroup = currentProxies.groups.find( - (group) => group.name === selectedGroup, - ); - if (!proxyChainGroup || !proxyChainGroup.now) { - setIsConnected(false); - return; - } - - // 检查当前选中的代理是否是配置的最后一个节点 - if (proxyChainGroup.now === lastNode.name) { - setIsConnected(true); - } else { - setIsConnected(false); - } + return currentProxies.global?.now === lastNode.name; } + + if (!selectedGroup || !Array.isArray(currentProxies.groups)) { + return false; + } + + const proxyChainGroup = currentProxies.groups.find( + (group) => group.name === selectedGroup, + ); + + return proxyChainGroup?.now === lastNode.name; }, [currentProxies, proxyChain, mode, selectedGroup]); // 监听链的变化,但排除从配置加载的情况 @@ -267,10 +242,10 @@ export const ProxyChain = ({ chainLengthRef.current !== proxyChain.length && chainLengthRef.current !== 0 ) { - setHasUnsavedChanges(true); + markUnsavedChanges(); } chainLengthRef.current = proxyChain.length; - }, [proxyChain.length]); + }, [proxyChain.length, markUnsavedChanges]); const sensors = useSensors( useSensor(PointerSensor), @@ -288,26 +263,21 @@ export const ProxyChain = ({ const newIndex = proxyChain.findIndex((item) => item.id === over?.id); onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex)); - setHasUnsavedChanges(true); + markUnsavedChanges(); } }, - [proxyChain, onUpdateChain], + [proxyChain, onUpdateChain, markUnsavedChanges], ); const handleRemoveProxy = useCallback( (id: string) => { const newChain = proxyChain.filter((item) => item.id !== id); onUpdateChain(newChain); - setHasUnsavedChanges(true); + markUnsavedChanges(); }, - [proxyChain, onUpdateChain], + [proxyChain, onUpdateChain, markUnsavedChanges], ); - const handleClearAll = useCallback(() => { - onUpdateChain([]); - setHasUnsavedChanges(true); - }, [onUpdateChain]); - const handleConnect = useCallback(async () => { if (isConnected) { // 如果已连接,则断开连接 @@ -327,10 +297,6 @@ export const ProxyChain = ({ // 清空链式代理配置UI // onUpdateChain([]); - // setHasUnsavedChanges(false); - - // 强制更新连接状态 - setIsConnected(false); } catch (error) { console.error("Failed to disconnect from proxy chain:", error); alert(t("Failed to disconnect from proxy chain") || "断开链式代理失败"); @@ -372,9 +338,6 @@ export const ProxyChain = ({ // 刷新代理信息以更新连接状态 mutateProxies(); - - // 清除未保存标记 - setHasUnsavedChanges(false); console.log("Successfully connected to proxy chain"); } catch (error) { console.error("Failed to connect to proxy chain:", error); @@ -411,7 +374,6 @@ export const ProxyChain = ({ delay: undefined, })) || []; onUpdateChain(chainItems); - setHasUnsavedChanges(false); } catch (parseError) { console.error("Failed to parse YAML:", parseError); onUpdateChain([]); @@ -435,7 +397,6 @@ export const ProxyChain = ({ delay: undefined, })) || []; onUpdateChain(chainItems); - setHasUnsavedChanges(false); } catch (jsonError) { console.error("Failed to parse as JSON either:", jsonError); onUpdateChain([]); @@ -448,7 +409,6 @@ export const ProxyChain = ({ } else if (chainConfigData === "") { // Empty string means no proxies available, show empty state onUpdateChain([]); - setHasUnsavedChanges(false); } }, [chainConfigData, onUpdateChain]); @@ -519,7 +479,6 @@ export const ProxyChain = ({ onClick={() => { updateProxyChainConfigInRuntime(null); onUpdateChain([]); - setHasUnsavedChanges(false); }} sx={{ color: theme.palette.error.main, diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index b548681d..25861183 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -46,6 +46,8 @@ interface ProxyChainItem { delay?: number; } +const VirtuosoFooter = () =>
; + export const ProxyGroups = (props: Props) => { const { t } = useTranslation(); const { mode, isChainMode = false, chainConfigData } = props; @@ -61,23 +63,25 @@ export const ProxyGroups = (props: Props) => { const { verge } = useVerge(); const { proxies: proxiesData } = useAppData(); + const groups = proxiesData?.groups; + const availableGroups = useMemo(() => groups ?? [], [groups]); - // 当链式代理模式且规则模式下,如果没有选择代理组,默认选择第一个 - useEffect(() => { - if ( - isChainMode && - mode === "rule" && - !selectedGroup && - proxiesData?.groups?.length > 0 - ) { - setSelectedGroup(proxiesData.groups[0].name); + const defaultRuleGroup = useMemo(() => { + if (isChainMode && mode === "rule" && availableGroups.length > 0) { + return availableGroups[0].name; } - }, [isChainMode, mode, selectedGroup, proxiesData]); + return null; + }, [availableGroups, isChainMode, mode]); + + const activeSelectedGroup = useMemo( + () => selectedGroup ?? defaultRuleGroup, + [selectedGroup, defaultRuleGroup], + ); const { renderList, onProxies, onHeadState } = useRenderList( mode, isChainMode, - selectedGroup, + activeSelectedGroup, ); const getGroupHeadState = useCallback( @@ -112,6 +116,8 @@ export const ProxyGroups = (props: Props) => { useEffect(() => { if (renderList.length === 0) return; + let restoreTimer: ReturnType | null = null; + try { const savedPositions = localStorage.getItem("proxy-scroll-positions"); if (savedPositions) { @@ -120,7 +126,7 @@ export const ProxyGroups = (props: Props) => { const savedPosition = positions[mode]; if (savedPosition !== undefined) { - setTimeout(() => { + restoreTimer = setTimeout(() => { virtuosoRef.current?.scrollTo({ top: savedPosition, behavior: "auto", @@ -131,6 +137,12 @@ export const ProxyGroups = (props: Props) => { } catch (e) { console.error("Error restoring scroll position:", e); } + + return () => { + if (restoreTimer) { + clearTimeout(restoreTimer); + } + }; }, [mode, renderList.length]); // 改为使用节流函数保存滚动位置 @@ -150,25 +162,30 @@ export const ProxyGroups = (props: Props) => { ); // 使用改进的滚动处理 - const handleScroll = useCallback( - throttle((e: any) => { - const scrollTop = e.target.scrollTop; - setShowScrollTop(scrollTop > 100); - // 使用稳定的节流来保存位置,而不是setTimeout - saveScrollPosition(scrollTop); - }, 500), // 增加到500ms以确保平滑滚动 + const handleScroll = useMemo( + () => + throttle((event: Event) => { + const target = event.target as HTMLElement | null; + const scrollTop = target?.scrollTop ?? 0; + setShowScrollTop(scrollTop > 100); + // 使用稳定的节流来保存位置,而不是setTimeout + saveScrollPosition(scrollTop); + }, 500), // 增加到500ms以确保平滑滚动 [saveScrollPosition], ); // 添加和清理滚动事件监听器 useEffect(() => { - if (!scrollerRef.current) return; - scrollerRef.current.addEventListener("scroll", handleScroll, { - passive: true, - }); + const node = scrollerRef.current; + if (!node) return; + + const listener = handleScroll as EventListener; + const options: AddEventListenerOptions = { passive: true }; + + node.addEventListener("scroll", listener, options); return () => { - scrollerRef.current?.removeEventListener("scroll", handleScroll); + node.removeEventListener("scroll", listener, options); }; }, [handleScroll]); @@ -186,18 +203,14 @@ export const ProxyGroups = (props: Props) => { setDuplicateWarning({ open: false, message: "" }); }, []); - // 获取当前选中的代理组信息 - const getCurrentGroup = useCallback(() => { - if (!selectedGroup || !proxiesData?.groups) return null; - return proxiesData.groups.find( - (group: any) => group.name === selectedGroup, + const currentGroup = useMemo(() => { + if (!activeSelectedGroup) return null; + return ( + availableGroups.find( + (group: any) => group.name === activeSelectedGroup, + ) ?? null ); - }, [selectedGroup, proxiesData]); - - // 获取可用的代理组列表 - const getAvailableGroups = useCallback(() => { - return proxiesData?.groups || []; - }, [proxiesData]); + }, [activeSelectedGroup, availableGroups]); // 处理代理组选择菜单 const handleGroupMenuOpen = (event: React.MouseEvent) => { @@ -220,9 +233,6 @@ export const ProxyGroups = (props: Props) => { } }; - const currentGroup = getCurrentGroup(); - const availableGroups = getAvailableGroups(); - const handleChangeProxy = useCallback( (group: IProxyGroupItem, proxy: IProxyItem) => { if (isChainMode) { @@ -472,7 +482,7 @@ export const ProxyGroups = (props: Props) => { scrollerRef.current = ref as Element; }} components={{ - Footer: () =>
, + Footer: VirtuosoFooter, }} initialScrollTop={scrollPositionRef.current[mode]} computeItemKey={(index) => renderList[index].key} @@ -498,7 +508,7 @@ export const ProxyGroups = (props: Props) => { onUpdateChain={setProxyChain} chainConfigData={chainConfigData} mode={mode} - selectedGroup={selectedGroup} + selectedGroup={activeSelectedGroup} /> @@ -530,11 +540,11 @@ export const ProxyGroups = (props: Props) => { }, }} > - {availableGroups.map((group: any, _index: number) => ( + {availableGroups.map((group: any) => ( handleGroupSelect(group.name)} - selected={selectedGroup === group.name} + selected={activeSelectedGroup === group.name} sx={{ fontSize: "14px", py: 1, @@ -591,7 +601,7 @@ export const ProxyGroups = (props: Props) => { scrollerRef.current = ref as Element; }} components={{ - Footer: () =>
, + Footer: VirtuosoFooter, }} // 添加平滑滚动设置 initialScrollTop={scrollPositionRef.current[mode]} diff --git a/src/components/proxy/proxy-head.tsx b/src/components/proxy/proxy-head.tsx index f10af411..5ba08958 100644 --- a/src/components/proxy/proxy-head.tsx +++ b/src/components/proxy/proxy-head.tsx @@ -31,8 +31,10 @@ interface Props { onHeadState: (val: Partial) => void; } +const defaultSx: SxProps = {}; + export const ProxyHead = ({ - sx = {}, + sx = defaultSx, url, groupName, headState, diff --git a/src/components/proxy/proxy-item-mini.tsx b/src/components/proxy/proxy-item-mini.tsx index 8e456f29..532dd659 100644 --- a/src/components/proxy/proxy-item-mini.tsx +++ b/src/components/proxy/proxy-item-mini.tsx @@ -1,7 +1,7 @@ import { CheckCircleOutlineRounded } from "@mui/icons-material"; import { alpha, Box, ListItemButton, styled, Typography } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { useTranslation } from "react-i18next"; import { BaseLoading } from "@/components/base"; @@ -26,7 +26,7 @@ export const ProxyItemMini = (props: Props) => { const isPreset = presetList.includes(proxy.name); // -1/<=0 为 不显示 // -2 为 loading - const [delay, setDelay] = useState(-1); + const [delay, setDelay] = useReducer((_: number, value: number) => value, -1); const { verge } = useVerge(); const timeout = verge?.default_latency_timeout || 10000; @@ -39,11 +39,15 @@ export const ProxyItemMini = (props: Props) => { }; }, [isPreset, proxy.name, group.name]); - useEffect(() => { + const updateDelay = useCallback(() => { if (!proxy) return; setDelay(delayManager.getDelayFix(proxy, group.name)); }, [proxy, group.name]); + useEffect(() => { + updateDelay(); + }, [updateDelay]); + const onDelay = useLockFn(async () => { setDelay(-2); setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout)); diff --git a/src/components/proxy/proxy-item.tsx b/src/components/proxy/proxy-item.tsx index 82308b0e..c356d6c4 100644 --- a/src/components/proxy/proxy-item.tsx +++ b/src/components/proxy/proxy-item.tsx @@ -11,7 +11,7 @@ import { Theme, } from "@mui/material"; import { useLockFn } from "ahooks"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { BaseLoading } from "@/components/base"; import { useVerge } from "@/hooks/use-verge"; @@ -51,7 +51,7 @@ export const ProxyItem = (props: Props) => { const isPreset = presetList.includes(proxy.name); // -1/<=0 为 不显示 // -2 为 loading - const [delay, setDelay] = useState(-1); + const [delay, setDelay] = useReducer((_: number, value: number) => value, -1); const { verge } = useVerge(); const timeout = verge?.default_latency_timeout || 10000; useEffect(() => { @@ -63,10 +63,14 @@ export const ProxyItem = (props: Props) => { }; }, [proxy.name, group.name, isPreset]); - useEffect(() => { + const updateDelay = useCallback(() => { if (!proxy) return; setDelay(delayManager.getDelayFix(proxy, group.name)); - }, [group.name, proxy]); + }, [proxy, group.name]); + + useEffect(() => { + updateDelay(); + }, [updateDelay]); const onDelay = useLockFn(async () => { setDelay(-2); diff --git a/src/components/proxy/proxy-render.tsx b/src/components/proxy/proxy-render.tsx index 5d565286..bb9919f5 100644 --- a/src/components/proxy/proxy-render.tsx +++ b/src/components/proxy/proxy-render.tsx @@ -14,7 +14,7 @@ import { Tooltip, } from "@mui/material"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useVerge } from "@/hooks/use-verge"; @@ -49,7 +49,7 @@ export const ProxyRender = (props: RenderProps) => { onCheckAll, onHeadState, onChangeProxy, - isChainMode = false, + isChainMode: _ = false, } = props; const { type, group, headState, proxy, proxyCol } = item; const { verge } = useVerge(); @@ -59,23 +59,42 @@ export const ProxyRender = (props: RenderProps) => { const itembackgroundcolor = isDark ? "#282A36" : "#ffffff"; const [iconCachePath, setIconCachePath] = useState(""); - useEffect(() => { - initIconCachePath(); - }, [group]); - - async function initIconCachePath() { + const initIconCachePath = useCallback(async () => { if (group.icon && group.icon.trim().startsWith("http")) { const fileName = group.name.replaceAll(" ", "") + "-" + getFileName(group.icon); const iconPath = await downloadIconCache(group.icon, fileName); setIconCachePath(convertFileSrc(iconPath)); + } else { + setIconCachePath(""); } - } + }, [group.icon, group.name]); + + useEffect(() => { + initIconCachePath(); + }, [initIconCachePath]); function getFileName(url: string) { return url.substring(url.lastIndexOf("/") + 1); } + const proxyColItemsMemo = useMemo(() => { + if (type !== 4 || !proxyCol) { + return null; + } + + return proxyCol.map((proxyItem) => ( + onChangeProxy(group, proxyItem!)} + /> + )); + }, [type, proxyCol, item.key, group, headState, onChangeProxy]); + if (type === 0) { return ( { } if (type === 4) { - const proxyColItemsMemo = useMemo(() => { - return proxyCol?.map((proxy) => ( - onChangeProxy(group, proxy!)} - /> - )); - }, [proxyCol, group, headState]); return ( count + 1, 0); useEffect(() => { let last = 0; @@ -21,7 +21,7 @@ export default function useFilterSort( const now = Date.now(); if (now - last > 666) { last = now; - setRefresh({}); + bumpRefresh(); } }); diff --git a/src/components/proxy/use-head-state.ts b/src/components/proxy/use-head-state.ts index 427bdff8..593290f7 100644 --- a/src/components/proxy/use-head-state.ts +++ b/src/components/proxy/use-head-state.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useReducer } from "react"; import { useProfiles } from "@/hooks/use-profiles"; @@ -25,15 +25,38 @@ export const DEFAULT_STATE: HeadState = { testUrl: "", }; +type HeadStateAction = + | { type: "reset" } + | { type: "replace"; payload: Record } + | { type: "update"; groupName: string; patch: Partial }; + +function headStateReducer( + state: Record, + action: HeadStateAction, +): Record { + switch (action.type) { + case "reset": + return {}; + case "replace": + return action.payload; + case "update": { + const prev = state[action.groupName] || DEFAULT_STATE; + return { ...state, [action.groupName]: { ...prev, ...action.patch } }; + } + default: + return state; + } +} + export function useHeadStateNew() { const { profiles } = useProfiles(); const current = profiles?.current || ""; - const [state, setState] = useState>({}); + const [state, dispatch] = useReducer(headStateReducer, {}); useEffect(() => { if (!current) { - setState({}); + dispatch({ type: "reset" }); return; } @@ -45,36 +68,39 @@ export function useHeadStateNew() { const value = data[current] || {}; if (value && typeof value === "object") { - setState(value); + dispatch({ type: "replace", payload: value }); } else { - setState({}); + dispatch({ type: "reset" }); } - } catch {} + } catch { + dispatch({ type: "reset" }); + } }, [current]); + useEffect(() => { + if (!current) return; + + const timer = setTimeout(() => { + try { + const item = localStorage.getItem(HEAD_STATE_KEY); + + let data = (item ? JSON.parse(item) : {}) as HeadStateStorage; + + if (!data || typeof data !== "object") data = {}; + + data[current] = state; + + localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data)); + } catch {} + }); + + return () => clearTimeout(timer); + }, [state, current]); + const setHeadState = useCallback( (groupName: string, obj: Partial) => { - setState((old) => { - const state = old[groupName] || DEFAULT_STATE; - const ret = { ...old, [groupName]: { ...state, ...obj } }; - - // 保存到存储中 - setTimeout(() => { - try { - const item = localStorage.getItem(HEAD_STATE_KEY); - - let data = (item ? JSON.parse(item) : {}) as HeadStateStorage; - - if (!data || typeof data !== "object") data = {}; - - data[current] = ret; - - localStorage.setItem(HEAD_STATE_KEY, JSON.stringify(data)); - } catch {} - }); - - return ret; - }); + if (!current) return; + dispatch({ type: "update", groupName, patch: obj }); }, [current], );