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 { useAppData } from "@/providers/app-data-context";
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 (
{proxy.name}
{proxy.type && (
)}
{proxy.delay !== undefined && (
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 }}
/>
)}
onRemove(proxy.id)}
sx={{
color: theme.palette.error.main,
"&:hover": {
backgroundColor: theme.palette.error.light + "20",
},
}}
>
);
};
export const ProxyChain = ({
proxyChain,
onUpdateChain,
chainConfigData,
onMarkUnsavedChanges,
mode,
selectedGroup,
}: ProxyChainProps) => {
const theme = useTheme();
const { t } = useTranslation();
const { proxies, refreshProxy } = useAppData();
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: { name: string }) => 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 (
{t("proxies.page.chain.header")}
{proxyChain.length > 0 && (
{
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") || "删除链式配置"
}
>
)}
: }
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") || "连接"}
{proxyChain.length === 1
? t("proxies.page.chain.minimumNodesHint") ||
"链式代理至少需要2个节点,请再添加一个节点。"
: t("proxies.page.chain.instruction") ||
"按顺序点击节点添加到代理链中"}
{proxyChain.length === 0 ? (
{t("proxies.page.chain.empty")}
) : (
proxy.id)}
strategy={verticalListSortingStrategy}
>
{proxyChain.map((proxy, index) => (
))}
)}
);
};