2025-09-22 18:16:17 +08:00

624 lines
17 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, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { useAppData } from "@/providers/app-data-provider";
import {
closeAllConnections,
getProxies,
updateProxyAndSync,
updateProxyChainConfigInRuntime,
} from "@/services/cmds";
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("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 } = 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;
}
// 获取用户配置的最后一个节点
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);
}
}
}, [currentProxies, proxyChain, mode, selectedGroup]);
// 监听链的变化,但排除从配置加载的情况
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}`);
// 根据模式确定使用的代理组名称
if (mode !== "global" && !selectedGroup) {
throw new Error("规则模式下必须选择代理组");
}
const targetGroup = mode === "global" ? "GLOBAL" : selectedGroup;
await updateProxyAndSync(targetGroup || "GLOBAL", lastNode.name);
localStorage.setItem("proxy-chain-group", targetGroup || "GLOBAL");
localStorage.setItem("proxy-chain-exit-node", 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, mode, selectedGroup]);
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 ||
(mode !== "global" && !selectedGroup)
}
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>
);
};