Add Func 链式代理 (#4624)

* 添加链式代理gui和语言支持
在Iruntime中添跟新链式代理配置方法
同时添加了cmd

* 修复读取运行时代理链配置文件bug

* t

* 完成链式代理配置构造

* 修复获取链式代理运行时配置的bug

* 完整的链式代理功能
This commit is contained in:
Junkai W.
2025-09-15 07:44:54 +08:00
committed by GitHub
parent a1f468202f
commit f2073a2f83
25 changed files with 1246 additions and 316 deletions

View File

@@ -10,190 +10,37 @@ 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 { Box, styled, Snackbar, Alert } 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",
willChange: "transform",
},
"& .letter-container": {
display: "flex",
flexDirection: "column",
gap: "2px",
transition: "transform 0.2s ease",
willChange: "transform",
},
"& .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,
enableAutoScroll = true,
}: {
name: string;
onClick: (name: string) => void;
getFirstChar: (str: string) => string;
enableAutoScroll?: boolean;
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState({
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
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);
// 只有在启用自动滚动时才触发滚动
if (enableAutoScroll) {
// 添加 100ms 的延迟,避免鼠标快速划过时触发滚动
hoverTimeoutRef.current = setTimeout(() => {
onClick(name);
}, 100);
}
}, [name, onClick, enableAutoScroll]);
const handleMouseLeave = useCallback(() => {
setShowTooltip(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
return (
<>
<div
ref={letterRef}
className="letter"
onClick={() => onClick(name)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span>{getFirstChar(name)}</span>
</div>
{showTooltip &&
createPortal(
<Tooltip
style={{
top: tooltipPosition.top,
right: tooltipPosition.right,
transform: "translateY(-50%)",
}}
>
{name}
</Tooltip>,
document.body,
)}
</>
);
},
);
import { ProxyChain } from "./proxy-chain";
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 { mode } = props;
const { mode, isChainMode = false, chainConfigData } = props;
const [proxyChain, setProxyChain] = useState<ProxyChainItem[]>([]);
const [duplicateWarning, setDuplicateWarning] = useState<{
open: boolean;
message: string;
}>({ open: false, message: "" });
const { renderList, onProxies, onHeadState } = useRenderList(mode);
const { renderList, onProxies, onHeadState } = useRenderList(
mode,
isChainMode,
);
const { verge } = useVerge();
@@ -208,46 +55,12 @@ export const ProxyGroups = (props: Props) => {
},
});
// 获取自动滚动开关状态,默认为 true
const enableAutoScroll = verge?.enable_hover_jump_navigator ?? true;
const timeout = verge?.default_latency_timeout || 10000;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const letterContainerRef = useRef<HTMLDivElement>(null);
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState("auto");
// 使用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{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const match = str.match(regex);
return match ? match[0] : str.charAt(0);
}, []);
// 从 localStorage 恢复滚动位置
useEffect(() => {
@@ -323,28 +136,49 @@ 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 handleCloseDuplicateWarning = useCallback(() => {
setDuplicateWarning({ open: false, message: "" });
}, []);
const handleChangeProxy = useCallback(
(group: IProxyGroupItem, proxy: IProxyItem) => {
if (isChainMode) {
// 使用函数式更新来避免状态延迟问题
setProxyChain((prev) => {
// 检查是否已经存在相同名称的代理,防止重复添加
if (prev.some((item) => item.name === proxy.name)) {
const warningMessage = t("Proxy node already exists in chain");
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],
[handleProxyGroupChange, isChainMode, t],
);
// 测全部延迟
@@ -417,74 +251,73 @@ export const ProxyGroups = (props: Props) => {
}
};
// 添加滚轮事件处理函数 - 改进为只在悬停时触发
const handleWheel = useCallback((e: WheelEvent) => {
// 只有当鼠标在字母选择器上时才处理滚轮事件
if (!alphabetSelectorRef.current?.contains(e.target as Node)) return;
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]);
// 监听窗口大小变化
// layout effect runs before paint
useEffect(() => {
// 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = () => {
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`);
};
updateMaxHeight();
window.addEventListener("resize", updateMaxHeight);
return () => {
window.removeEventListener("resize", updateMaxHeight);
};
}, []);
if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />;
}
if (isChainMode) {
return (
<>
<Box sx={{ display: "flex", height: "100%", gap: 2 }}>
<Box sx={{ flex: 1, position: "relative" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
}}
components={{
Footer: () => <div style={{ height: "8px" }} />,
}}
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
isChainMode={isChainMode}
/>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
</Box>
<Box sx={{ width: "400px", minWidth: "300px" }}>
<ProxyChain
proxyChain={proxyChain}
onUpdateChain={setProxyChain}
chainConfigData={chainConfigData}
/>
</Box>
</Box>
<Snackbar
open={duplicateWarning.open}
autoHideDuration={3000}
onClose={handleCloseDuplicateWarning}
anchorOrigin={{ vertical: "top", horizontal: "center" }}
>
<Alert
onClose={handleCloseDuplicateWarning}
severity="warning"
variant="filled"
>
{duplicateWarning.message}
</Alert>
</Snackbar>
</>
);
}
return (
<div
style={{ position: "relative", height: "100%", willChange: "transform" }}
@@ -518,22 +351,6 @@ export const ProxyGroups = (props: Props) => {
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
<div className="scroll-container">
<div ref={letterContainerRef} className="letter-container">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
enableAutoScroll={enableAutoScroll}
/>
))}
</div>
</div>
</AlphabetSelector>
</div>
);
};