Files
clash-verge-rev/src/components/proxy/proxy-chain.tsx

597 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Delete as DeleteIcon,
DragIndicator,
Link,
LinkOff,
} from "@mui/icons-material";
import {
Alert,
Box,
Button,
Chip,
IconButton,
Paper,
Typography,
useTheme,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
closeAllConnections,
selectNodeForGroup,
} from "tauri-plugin-mihomo-api";
import { useProxiesData } from "@/hooks/use-clash-data";
import { updateProxyChainConfigInRuntime } from "@/services/cmds";
import { debugLog } from "@/utils/debug";
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;
mode?: string;
selectedGroup?: string | null;
}
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("shared.labels.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,
mode,
selectedGroup,
}: ProxyChainProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { proxies, refreshProxy } = useProxiesData();
const [isConnecting, setIsConnecting] = useState(false);
const markUnsavedChanges = useCallback(() => {
onMarkUnsavedChanges?.();
}, [onMarkUnsavedChanges]);
const isConnected = useMemo(() => {
if (!proxies || proxyChain.length < 2) {
return false;
}
const lastNode = proxyChain[proxyChain.length - 1];
if (mode === "global") {
return proxies.global?.now === lastNode.name;
}
if (!selectedGroup || !Array.isArray(proxies.groups)) {
return false;
}
const proxyChainGroup = proxies.groups.find(
(group) => group.name === selectedGroup,
);
return proxyChainGroup?.now === lastNode.name;
}, [proxies, proxyChain, mode, selectedGroup]);
// 监听链的变化,但排除从配置加载的情况
const chainLengthRef = useRef(proxyChain.length);
useEffect(() => {
// 只有当链长度发生变化且不是初始加载时,才标记为未保存
if (
chainLengthRef.current !== proxyChain.length &&
chainLengthRef.current !== 0
) {
markUnsavedChanges();
}
chainLengthRef.current = proxyChain.length;
}, [proxyChain.length, markUnsavedChanges]);
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));
markUnsavedChanges();
}
},
[proxyChain, onUpdateChain, markUnsavedChanges],
);
const handleRemoveProxy = useCallback(
(id: string) => {
const newChain = proxyChain.filter((item) => item.id !== id);
onUpdateChain(newChain);
markUnsavedChanges();
},
[proxyChain, onUpdateChain, markUnsavedChanges],
);
const handleConnect = useCallback(async () => {
if (isConnected) {
setIsConnecting(true);
try {
await updateProxyChainConfigInRuntime(null);
const targetGroup =
mode === "global"
? "GLOBAL"
: selectedGroup || localStorage.getItem("proxy-chain-group");
if (targetGroup) {
try {
await selectNodeForGroup(targetGroup, "DIRECT");
} catch {
if (proxyChain.length >= 1) {
try {
await selectNodeForGroup(targetGroup, proxyChain[0].name);
} catch {
// ignore
}
}
}
}
localStorage.removeItem("proxy-chain-group");
localStorage.removeItem("proxy-chain-exit-node");
localStorage.removeItem("proxy-chain-items");
await closeAllConnections();
await refreshProxy();
onUpdateChain([]);
} catch (error) {
console.error("Failed to disconnect from proxy chain:", error);
alert(t("proxies.page.chain.disconnectFailed") || "断开链式代理失败");
} finally {
setIsConnecting(false);
}
return;
}
if (proxyChain.length < 2) {
alert(t("proxies.page.chain.minimumNodes") || "链式代理至少需要2个节点");
return;
}
setIsConnecting(true);
try {
// 第一步:保存链式代理配置
const chainProxies = proxyChain.map((node) => node.name);
debugLog("Saving chain config:", chainProxies);
await updateProxyChainConfigInRuntime(chainProxies);
debugLog("Chain configuration saved successfully");
// 第二步:连接到代理链的最后一个节点
const lastNode = proxyChain[proxyChain.length - 1];
debugLog(`Connecting to proxy chain, last node: ${lastNode.name}`);
// 根据模式确定使用的代理组名称
if (mode !== "global" && !selectedGroup) {
throw new Error("规则模式下必须选择代理组");
}
const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup;
await selectNodeForGroup(targetGroup || "GLOBAL", lastNode.name);
localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL");
localStorage.setItem("proxy-chain-exit-node", lastNode.name);
// 刷新代理信息以更新连接状态
refreshProxy();
debugLog("Successfully connected to proxy chain");
} catch (error) {
console.error("Failed to connect to proxy chain:", error);
alert(t("proxies.page.chain.connectFailed") || "连接链式代理失败");
} finally {
setIsConnecting(false);
}
}, [
proxyChain,
isConnected,
t,
refreshProxy,
mode,
selectedGroup,
onUpdateChain,
]);
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,
})) || [];
if (chainItems.length > 0) {
onUpdateChain(chainItems);
}
} catch (parseError) {
console.error("Failed to parse YAML:", parseError);
}
})
.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,
})) || [];
if (chainItems.length > 0) {
onUpdateChain(chainItems);
}
} catch (jsonError) {
console.error("Failed to parse as JSON either:", jsonError);
}
});
} catch (error) {
console.error("Failed to process chain config data:", error);
}
}
}, [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("proxies.page.chain.header")}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{proxyChain.length > 0 && (
<IconButton
size="small"
onClick={() => {
updateProxyChainConfigInRuntime(null);
localStorage.removeItem("proxy-chain-group");
localStorage.removeItem("proxy-chain-exit-node");
localStorage.removeItem("proxy-chain-items");
onUpdateChain([]);
}}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
},
}}
title={
t("proxies.page.actions.clearChainConfig") || "删除链式配置"
}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
<Button
size="small"
variant="contained"
startIcon={isConnected ? <LinkOff /> : <Link />}
onClick={handleConnect}
disabled={
isConnecting ||
proxyChain.length < 2 ||
(mode !== "global" && !selectedGroup)
}
color={isConnected ? "error" : "success"}
sx={{
minWidth: 90,
}}
title={
proxyChain.length < 2
? t("proxies.page.chain.minimumNodes") ||
"链式代理至少需要2个节点"
: undefined
}
>
{isConnecting
? t("proxies.page.actions.connecting") || "连接中..."
: isConnected
? t("proxies.page.actions.disconnect") || "断开"
: t("proxies.page.actions.connect") || "连接"}
</Button>
</Box>
</Box>
<Alert
severity={proxyChain.length === 1 ? "warning" : "info"}
sx={{ mb: 2 }}
>
{proxyChain.length === 1
? t("proxies.page.chain.minimumNodesHint") ||
"链式代理至少需要2个节点请再添加一个节点。"
: t("proxies.page.chain.instruction") ||
"按顺序点击节点添加到代理链中"}
</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("proxies.page.chain.empty")}</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>
);
};