Add Func 链式代理 (#4624)
* 添加链式代理gui和语言支持 在Iruntime中添跟新链式代理配置方法 同时添加了cmd * 修复读取运行时代理链配置文件bug * t * 完成链式代理配置构造 * 修复获取链式代理运行时配置的bug * 完整的链式代理功能
This commit is contained in:
586
src/components/proxy/proxy-chain.tsx
Normal file
586
src/components/proxy/proxy-chain.tsx
Normal file
@@ -0,0 +1,586 @@
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
IconButton,
|
||||
Chip,
|
||||
Alert,
|
||||
useTheme,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppData } from "@/providers/app-data-provider";
|
||||
import {
|
||||
updateProxyChainConfigInRuntime,
|
||||
updateProxyAndSync,
|
||||
getProxies,
|
||||
closeAllConnections,
|
||||
} from "@/services/cmds";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
DragIndicator,
|
||||
ClearAll,
|
||||
Save,
|
||||
Link,
|
||||
LinkOff,
|
||||
} from "@mui/icons-material";
|
||||
|
||||
interface ProxyChainItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
interface ParsedChainConfig {
|
||||
proxies?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ProxyChainProps {
|
||||
proxyChain: ProxyChainItem[];
|
||||
onUpdateChain: (chain: ProxyChainItem[]) => void;
|
||||
chainConfigData?: string | null;
|
||||
onMarkUnsavedChanges?: () => void;
|
||||
}
|
||||
|
||||
interface SortableItemProps {
|
||||
proxy: ProxyChainItem;
|
||||
index: number;
|
||||
onRemove: (id: string) => void;
|
||||
}
|
||||
|
||||
const SortableItem = ({ proxy, index, onRemove }: SortableItemProps) => {
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: proxy.id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
sx={{
|
||||
mb: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
p: 1,
|
||||
backgroundColor: isDragging
|
||||
? theme.palette.action.selected
|
||||
: theme.palette.background.default,
|
||||
borderRadius: 1,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
boxShadow: isDragging ? theme.shadows[4] : theme.shadows[1],
|
||||
transition: "box-shadow 0.2s, background-color 0.2s",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
mr: 1,
|
||||
color: theme.palette.text.secondary,
|
||||
cursor: "grab",
|
||||
"&:active": {
|
||||
cursor: "grabbing",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DragIndicator />
|
||||
</Box>
|
||||
|
||||
<Chip
|
||||
label={`${index + 1}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ mr: 1, minWidth: 32 }}
|
||||
/>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
flex: 1,
|
||||
fontWeight: 500,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{proxy.name}
|
||||
</Typography>
|
||||
|
||||
{proxy.type && (
|
||||
<Chip
|
||||
label={proxy.type}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{proxy.delay !== undefined && (
|
||||
<Chip
|
||||
label={proxy.delay > 0 ? `${proxy.delay}ms` : t("timeout") || "超时"}
|
||||
size="small"
|
||||
color={
|
||||
proxy.delay > 0 && proxy.delay < 200
|
||||
? "success"
|
||||
: proxy.delay > 0 && proxy.delay < 800
|
||||
? "warning"
|
||||
: "error"
|
||||
}
|
||||
sx={{ mr: 1, fontSize: "0.7rem", minWidth: 50 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onRemove(proxy.id)}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.error.light + "20",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProxyChain = ({
|
||||
proxyChain,
|
||||
onUpdateChain,
|
||||
chainConfigData,
|
||||
onMarkUnsavedChanges,
|
||||
}: ProxyChainProps) => {
|
||||
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 { data: currentProxies, mutate: mutateProxies } = useSWR(
|
||||
"getProxies",
|
||||
getProxies,
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
revalidateIfStale: true,
|
||||
refreshInterval: 5000, // 每5秒刷新一次
|
||||
},
|
||||
);
|
||||
|
||||
// 检查连接状态
|
||||
useEffect(() => {
|
||||
if (!currentProxies || proxyChain.length < 2) {
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找 proxy_chain 代理组
|
||||
const proxyChainGroup = currentProxies.groups.find(
|
||||
(group) => group.name === "proxy_chain",
|
||||
);
|
||||
if (!proxyChainGroup || !proxyChainGroup.now) {
|
||||
setIsConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户配置的最后一个节点
|
||||
const lastNode = proxyChain[proxyChain.length - 1];
|
||||
|
||||
// 检查当前选中的代理是否是配置的最后一个节点
|
||||
if (proxyChainGroup.now === lastNode.name) {
|
||||
setIsConnected(true);
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [currentProxies, proxyChain]);
|
||||
|
||||
// 监听链的变化,但排除从配置加载的情况
|
||||
const chainLengthRef = useRef(proxyChain.length);
|
||||
useEffect(() => {
|
||||
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
|
||||
if (
|
||||
chainLengthRef.current !== proxyChain.length &&
|
||||
chainLengthRef.current !== 0
|
||||
) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
chainLengthRef.current = proxyChain.length;
|
||||
}, [proxyChain.length]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = proxyChain.findIndex((item) => item.id === active.id);
|
||||
const newIndex = proxyChain.findIndex((item) => item.id === over?.id);
|
||||
|
||||
onUpdateChain(arrayMove(proxyChain, oldIndex, newIndex));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
},
|
||||
[proxyChain, onUpdateChain],
|
||||
);
|
||||
|
||||
const handleRemoveProxy = useCallback(
|
||||
(id: string) => {
|
||||
const newChain = proxyChain.filter((item) => item.id !== id);
|
||||
onUpdateChain(newChain);
|
||||
setHasUnsavedChanges(true);
|
||||
},
|
||||
[proxyChain, onUpdateChain],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
onUpdateChain([]);
|
||||
setHasUnsavedChanges(true);
|
||||
}, [onUpdateChain]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (isConnected) {
|
||||
// 如果已连接,则断开连接
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// 清空链式代理配置
|
||||
await updateProxyChainConfigInRuntime(null);
|
||||
|
||||
// 切换到 DIRECT 模式断开代理连接
|
||||
// await updateProxyAndSync("GLOBAL", "DIRECT");
|
||||
|
||||
// 关闭所有连接
|
||||
await closeAllConnections();
|
||||
|
||||
// 刷新代理信息以更新连接状态
|
||||
mutateProxies();
|
||||
|
||||
// 清空链式代理配置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") || "断开链式代理失败");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (proxyChain.length < 2) {
|
||||
alert(
|
||||
t("Chain proxy requires at least 2 nodes") || "链式代理至少需要2个节点",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
// 第一步:保存链式代理配置
|
||||
const chainProxies = proxyChain.map((node) => node.name);
|
||||
console.log("Saving chain config:", chainProxies);
|
||||
await updateProxyChainConfigInRuntime(chainProxies);
|
||||
console.log("Chain configuration saved successfully");
|
||||
|
||||
// 第二步:连接到代理链的最后一个节点
|
||||
const lastNode = proxyChain[proxyChain.length - 1];
|
||||
console.log(`Connecting to proxy chain, last node: ${lastNode.name}`);
|
||||
await updateProxyAndSync("proxy_chain", lastNode.name);
|
||||
|
||||
// 刷新代理信息以更新连接状态
|
||||
mutateProxies();
|
||||
|
||||
// 清除未保存标记
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
console.log("Successfully connected to proxy chain");
|
||||
} catch (error) {
|
||||
console.error("Failed to connect to proxy chain:", error);
|
||||
alert(t("Failed to connect to proxy chain") || "连接链式代理失败");
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [proxyChain, isConnected, t, mutateProxies]);
|
||||
|
||||
const proxyChainRef = useRef(proxyChain);
|
||||
const onUpdateChainRef = useRef(onUpdateChain);
|
||||
|
||||
useEffect(() => {
|
||||
proxyChainRef.current = proxyChain;
|
||||
onUpdateChainRef.current = onUpdateChain;
|
||||
}, [proxyChain, onUpdateChain]);
|
||||
|
||||
// 处理链式代理配置数据
|
||||
useEffect(() => {
|
||||
if (chainConfigData) {
|
||||
try {
|
||||
// Try to parse as YAML using dynamic import
|
||||
import("js-yaml")
|
||||
.then((yaml) => {
|
||||
try {
|
||||
const parsedConfig = yaml.load(
|
||||
chainConfigData,
|
||||
) as ParsedChainConfig;
|
||||
const chainItems =
|
||||
parsedConfig?.proxies?.map((proxy, index: number) => ({
|
||||
id: `${proxy.name}_${Date.now()}_${index}`,
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
delay: undefined,
|
||||
})) || [];
|
||||
onUpdateChain(chainItems);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse YAML:", parseError);
|
||||
onUpdateChain([]);
|
||||
}
|
||||
})
|
||||
.catch((importError) => {
|
||||
// Fallback: try to parse as JSON if YAML is not available
|
||||
console.warn(
|
||||
"js-yaml not available, trying JSON parse:",
|
||||
importError,
|
||||
);
|
||||
try {
|
||||
const parsedConfig = JSON.parse(
|
||||
chainConfigData,
|
||||
) as ParsedChainConfig;
|
||||
const chainItems =
|
||||
parsedConfig?.proxies?.map((proxy, index: number) => ({
|
||||
id: `${proxy.name}_${Date.now()}_${index}`,
|
||||
name: proxy.name,
|
||||
type: proxy.type,
|
||||
delay: undefined,
|
||||
})) || [];
|
||||
onUpdateChain(chainItems);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (jsonError) {
|
||||
console.error("Failed to parse as JSON either:", jsonError);
|
||||
onUpdateChain([]);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to process chain config data:", error);
|
||||
onUpdateChain([]);
|
||||
}
|
||||
} else if (chainConfigData === "") {
|
||||
// Empty string means no proxies available, show empty state
|
||||
onUpdateChain([]);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
}, [chainConfigData, onUpdateChain]);
|
||||
|
||||
// 定时更新延迟数据
|
||||
useEffect(() => {
|
||||
if (!proxies?.records) return;
|
||||
|
||||
const updateDelays = () => {
|
||||
const currentChain = proxyChainRef.current;
|
||||
if (currentChain.length === 0) return;
|
||||
|
||||
const updatedChain = currentChain.map((item) => {
|
||||
const proxyRecord = proxies.records[item.name];
|
||||
if (
|
||||
proxyRecord &&
|
||||
proxyRecord.history &&
|
||||
proxyRecord.history.length > 0
|
||||
) {
|
||||
const latestDelay =
|
||||
proxyRecord.history[proxyRecord.history.length - 1].delay;
|
||||
return { ...item, delay: latestDelay };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
// 只有在延迟数据确实发生变化时才更新
|
||||
const hasChanged = updatedChain.some(
|
||||
(item, index) => item.delay !== currentChain[index]?.delay,
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
onUpdateChainRef.current(updatedChain);
|
||||
}
|
||||
};
|
||||
|
||||
// 立即更新一次延迟
|
||||
updateDelays();
|
||||
|
||||
// 设置定时器,每5秒更新一次延迟
|
||||
const interval = setInterval(updateDelays, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [proxies?.records]); // 只依赖proxies.records
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={1}
|
||||
sx={{
|
||||
height: "100%",
|
||||
p: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6">{t("Chain Proxy Config")}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{proxyChain.length > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
updateProxyChainConfigInRuntime(null);
|
||||
onUpdateChain([]);
|
||||
setHasUnsavedChanges(false);
|
||||
}}
|
||||
sx={{
|
||||
color: theme.palette.error.main,
|
||||
"&:hover": {
|
||||
backgroundColor: theme.palette.error.light + "20",
|
||||
},
|
||||
}}
|
||||
title={t("Delete Chain Config") || "删除链式配置"}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={isConnected ? <LinkOff /> : <Link />}
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting || proxyChain.length < 2}
|
||||
color={isConnected ? "error" : "success"}
|
||||
sx={{
|
||||
minWidth: 90,
|
||||
}}
|
||||
title={
|
||||
proxyChain.length < 2
|
||||
? t("Chain proxy requires at least 2 nodes") ||
|
||||
"链式代理至少需要2个节点"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isConnecting
|
||||
? t("Connecting...") || "连接中..."
|
||||
: isConnected
|
||||
? t("Disconnect") || "断开"
|
||||
: t("Connect") || "连接"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={proxyChain.length === 1 ? "warning" : "info"}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{proxyChain.length === 1
|
||||
? t(
|
||||
"Chain proxy requires at least 2 nodes. Please add one more node.",
|
||||
) || "链式代理至少需要2个节点,请再添加一个节点。"
|
||||
: t("Click nodes in order to add to proxy chain") ||
|
||||
"按顺序点击节点添加到代理链中"}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: "auto" }}>
|
||||
{proxyChain.length === 0 ? (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100%",
|
||||
color: theme.palette.text.secondary,
|
||||
}}
|
||||
>
|
||||
<Typography>{t("No proxy chain configured")}</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={proxyChain.map((proxy) => proxy.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 1,
|
||||
minHeight: 60,
|
||||
p: 1,
|
||||
}}
|
||||
>
|
||||
{proxyChain.map((proxy, index) => (
|
||||
<SortableItem
|
||||
key={proxy.id}
|
||||
proxy={proxy}
|
||||
index={index}
|
||||
onRemove={handleRemoveProxy}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslation } from "react-i18next";
|
||||
interface RenderProps {
|
||||
item: IRenderItem;
|
||||
indent: boolean;
|
||||
isChainMode?: boolean;
|
||||
onLocation: (group: IRenderItem["group"]) => void;
|
||||
onCheckAll: (groupName: string) => void;
|
||||
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
|
||||
@@ -39,8 +40,15 @@ interface RenderProps {
|
||||
|
||||
export const ProxyRender = (props: RenderProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { indent, item, onLocation, onCheckAll, onHeadState, onChangeProxy } =
|
||||
props;
|
||||
const {
|
||||
indent,
|
||||
item,
|
||||
onLocation,
|
||||
onCheckAll,
|
||||
onHeadState,
|
||||
onChangeProxy,
|
||||
isChainMode = false,
|
||||
} = props;
|
||||
const { type, group, headState, proxy, proxyCol } = item;
|
||||
const { verge } = useVerge();
|
||||
const enable_group_icon = verge?.enable_group_icon ?? true;
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type HeadState,
|
||||
} from "./use-head-state";
|
||||
import { useAppData } from "@/providers/app-data-provider";
|
||||
import useSWR from "swr";
|
||||
import { getRuntimeConfig } from "@/services/cmds";
|
||||
import delayManager from "@/services/delay";
|
||||
|
||||
// 定义代理项接口
|
||||
interface IProxyItem {
|
||||
@@ -88,13 +91,23 @@ const groupProxies = <T = any>(list: T[], size: number): T[][] => {
|
||||
}, [] as T[][]);
|
||||
};
|
||||
|
||||
export const useRenderList = (mode: string) => {
|
||||
export const useRenderList = (mode: string, isChainMode?: boolean) => {
|
||||
// 使用全局数据提供者
|
||||
const { proxies: proxiesData, refreshProxy } = useAppData();
|
||||
const { verge } = useVerge();
|
||||
const { width } = useWindowWidth();
|
||||
const [headStates, setHeadState] = useHeadStateNew();
|
||||
|
||||
// 获取运行时配置用于链式代理模式
|
||||
const { data: runtimeConfig } = useSWR(
|
||||
isChainMode ? "getRuntimeConfig" : null,
|
||||
getRuntimeConfig,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
},
|
||||
);
|
||||
|
||||
// 计算列数
|
||||
const col = useMemo(
|
||||
() => calculateColumns(width, verge?.proxy_layout_column || 6),
|
||||
@@ -115,10 +128,116 @@ export const useRenderList = (mode: string) => {
|
||||
}
|
||||
}, [proxiesData, mode, refreshProxy]);
|
||||
|
||||
// 链式代理模式节点自动计算延迟
|
||||
useEffect(() => {
|
||||
if (!isChainMode || !runtimeConfig) return;
|
||||
|
||||
const allProxies: IProxyItem[] = Object.values(
|
||||
(runtimeConfig as any).proxies || {},
|
||||
);
|
||||
if (allProxies.length === 0) return;
|
||||
|
||||
// 设置组监听器,当有延迟更新时自动刷新
|
||||
const groupListener = () => {
|
||||
console.log("[ChainMode] 延迟更新,刷新UI");
|
||||
refreshProxy();
|
||||
};
|
||||
|
||||
delayManager.setGroupListener("chain-mode", groupListener);
|
||||
|
||||
const calculateDelays = async () => {
|
||||
try {
|
||||
const timeout = verge?.default_latency_timeout || 10000;
|
||||
const proxyNames = allProxies.map((proxy) => proxy.name);
|
||||
|
||||
console.log(`[ChainMode] 开始计算 ${proxyNames.length} 个节点的延迟`);
|
||||
|
||||
// 使用 delayManager 计算延迟,每个节点计算完成后会自动触发监听器刷新界面
|
||||
delayManager.checkListDelay(proxyNames, "chain-mode", timeout);
|
||||
} catch (error) {
|
||||
console.error("Failed to calculate delays for chain mode:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 延迟执行避免阻塞
|
||||
const handle = setTimeout(calculateDelays, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
// 清理组监听器
|
||||
delayManager.removeGroupListener("chain-mode");
|
||||
};
|
||||
}, [
|
||||
isChainMode,
|
||||
runtimeConfig,
|
||||
verge?.default_latency_timeout,
|
||||
refreshProxy,
|
||||
]);
|
||||
|
||||
// 处理渲染列表
|
||||
const renderList: IRenderItem[] = useMemo(() => {
|
||||
if (!proxiesData) return [];
|
||||
|
||||
// 链式代理模式下,从运行时配置读取所有 proxies
|
||||
if (isChainMode && runtimeConfig) {
|
||||
// 从运行时配置直接获取 proxies 列表 (需要类型断言)
|
||||
const allProxies: IProxyItem[] = Object.values(
|
||||
(runtimeConfig as any).proxies || {},
|
||||
);
|
||||
|
||||
// 为每个节点获取延迟信息
|
||||
const proxiesWithDelay = allProxies.map((proxy) => {
|
||||
const delay = delayManager.getDelay(proxy.name, "chain-mode");
|
||||
return {
|
||||
...proxy,
|
||||
// 如果delayManager有延迟数据,更新history
|
||||
history:
|
||||
delay >= 0
|
||||
? [{ time: new Date().toISOString(), delay }]
|
||||
: proxy.history || [],
|
||||
};
|
||||
});
|
||||
|
||||
// 创建一个虚拟的组来容纳所有节点
|
||||
const virtualGroup: ProxyGroup = {
|
||||
name: "All Proxies",
|
||||
type: "Selector",
|
||||
udp: false,
|
||||
xudp: false,
|
||||
tfo: false,
|
||||
mptcp: false,
|
||||
smux: false,
|
||||
history: [],
|
||||
now: "",
|
||||
all: proxiesWithDelay,
|
||||
};
|
||||
|
||||
// 返回节点列表(不显示组头)
|
||||
if (col > 1) {
|
||||
return groupProxies(proxiesWithDelay, col).map(
|
||||
(proxyCol, colIndex) => ({
|
||||
type: 4,
|
||||
key: `chain-col-${colIndex}`,
|
||||
group: virtualGroup,
|
||||
headState: DEFAULT_STATE,
|
||||
col,
|
||||
proxyCol,
|
||||
provider: proxyCol[0]?.provider,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
return proxiesWithDelay.map((proxy) => ({
|
||||
type: 2,
|
||||
key: `chain-${proxy.name}`,
|
||||
group: virtualGroup,
|
||||
proxy,
|
||||
headState: DEFAULT_STATE,
|
||||
provider: proxy.provider,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 正常模式的渲染逻辑
|
||||
const useRule = mode === "rule" || mode === "script";
|
||||
const renderGroups =
|
||||
useRule && proxiesData.groups.length
|
||||
@@ -190,7 +309,7 @@ export const useRenderList = (mode: string) => {
|
||||
|
||||
if (!useRule) return retList.slice(1);
|
||||
return retList.filter((item: IRenderItem) => !item.group.hidden);
|
||||
}, [headStates, proxiesData, mode, col]);
|
||||
}, [headStates, proxiesData, mode, col, isChainMode, runtimeConfig]);
|
||||
|
||||
return {
|
||||
renderList,
|
||||
|
||||
Reference in New Issue
Block a user