mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-01-28 07:14:40 +08:00
* Revert "refactor(app-data): split monolithic context into focused SWR hooks (#5576)"
This reverts commit 8e8182f707.
# Conflicts:
# src/components/home/clash-info-card.tsx
# src/components/home/clash-mode-card.tsx
# src/components/home/current-proxy-card.tsx
# src/components/home/home-profile-card.tsx
# src/components/proxy/provider-button.tsx
# src/components/proxy/proxy-chain.tsx
# src/components/proxy/proxy-groups.tsx
# src/components/proxy/use-render-list.ts
# src/components/rule/provider-button.tsx
# src/components/setting/mods/sysproxy-viewer.tsx
# src/hooks/use-clash-data.ts
# src/hooks/use-current-proxy.ts
# src/hooks/use-shared-swr-poller.ts
# src/hooks/use-system-proxy-state.ts
# src/pages/rules.tsx
* docs: Changelog.md
597 lines
16 KiB
TypeScript
597 lines
16 KiB
TypeScript
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 (
|
||
<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 } = 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 (
|
||
<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>
|
||
);
|
||
};
|