feat: add AppDataProvider for centralized app data management and optimized refresh logic

This commit is contained in:
wonfen 2025-03-26 13:26:32 +08:00
parent 804fad6083
commit 5a0eb56f70
18 changed files with 1142 additions and 784 deletions

View File

@ -4,8 +4,12 @@
- 仅在Ubuntu 22.04/24.04Fedora 41 **Gnome桌面环境** 做过简单测试不保证其他其他Linux发行版可用将在未来做进一步适配和调优
### 2.2.3-alpha 相对于 2.2.2
#### 修复了:
- 首页“当前代理”因为重复刷新导致的CPU占用过高的问题
#### 优化
- 重构了内核管理逻辑,更轻量化和有效的管理内核,提高了性能和稳定性
- 集中管理应用数据,优化数据获取和刷新逻辑
## v2.2.2
@ -18,10 +22,10 @@
#### 已知问题
- 仅在Ubuntu 22.04/24.04Fedora 41 **Gnome桌面环境** 做过简单测试不保证其他其他Linux发行版可用将在未来做进一步适配和调优
### 2.2.2 相对于 2.2.1(已下架不提供)
### 2.2.2 相对于 2.2.1(已下架不提供)
#### 修复了:
- 弹黑框的问题(原因是服务崩溃触发重装机制)
- MacOS进入轻量模式以后藏Dock图标
- MacOS进入轻量模式以后藏Dock图标
- 增加轻量模式缺失的tray翻译
- Linux下的窗口边框被削掉的问题
@ -31,7 +35,7 @@
- 增加服务模式下的僵尸进程清理机制
- 新增当服务模式多次尝试失败后自动回退至用户空间模式
### 2.2.1 相对于 2.2.0(已下架不提供)
### 2.2.1 相对于 2.2.0(已下架不提供)
#### 修复了:
1. **首页**
- 修复 Direct 模式首页无法渲染
@ -62,7 +66,7 @@
---
## 2.2.0(已下架不提供)
## 2.2.0(已下架不提供)
#### 新增功能
1. **首页**
@ -141,14 +145,14 @@
感谢 Tychristine 对社区群组管理做出的重大贡献!
##### 2.1.2相对2.1.1(已下架不提供)更新了:
##### 2.1.2相对2.1.1(已下架不提供)更新了:
- 无法更新和签名验证失败的问题(该死的CDN缓存)
- 设置菜单区分Verge基本设置和高级设置
- 增加v2 Updater的更多功能和权限
- 退出Verge后Tun代理状态仍保留的问题
##### 2.1.1相对2.1.0(已下架不提供)更新了:
##### 2.1.1相对2.1.0(已下架不提供)更新了:
- 检测所需的Clash Verge Service版本杀毒软件误报可能与此有关因为检测和安装新版本Service需管理员权限
- MacOS下支持彩色托盘图标和更好速率显示感谢Tunglies

13
src/App.tsx Normal file
View File

@ -0,0 +1,13 @@
import { AppDataProvider } from "./providers/app-data-provider";
import React from "react";
import Layout from "./pages/_layout";
function App() {
return (
<AppDataProvider>
<Layout />
</AppDataProvider>
);
}
export default App;

View File

@ -1,13 +1,10 @@
import { useTranslation } from "react-i18next";
import { Typography, Stack, Divider } from "@mui/material";
import { DeveloperBoardOutlined } from "@mui/icons-material";
import { useClashInfo } from "@/hooks/use-clash";
import { useClash } from "@/hooks/use-clash";
import { EnhancedCard } from "./enhanced-card";
import useSWR from "swr";
import { getRules } from "@/services/api";
import { getAppUptime, getSystemProxy } from "@/services/cmds";
import { useMemo, useState, useEffect } from "react";
import { useMemo } from "react";
import { useAppData } from "@/providers/app-data-provider";
// 将毫秒转换为时:分:秒格式的函数
const formatUptime = (uptimeMs: number) => {
@ -19,37 +16,15 @@ const formatUptime = (uptimeMs: number) => {
export const ClashInfoCard = () => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { version: clashVersion } = useClash();
const [sysproxy, setSysproxy] = useState<{ server: string; enable: boolean; bypass: string } | null>(null);
const [rules, setRules] = useState<any[]>([]);
// 使用SWR获取应用运行时间降低更新频率
const { data: uptimeMs = 0 } = useSWR(
"appUptime",
getAppUptime,
{
refreshInterval: 1000,
revalidateOnFocus: false,
dedupingInterval: 1000,
},
);
// 在组件加载时获取系统代理信息和规则数据
useEffect(() => {
// 获取系统代理信息
getSystemProxy().then(setSysproxy);
// 获取规则数据
getRules().then(setRules).catch(() => setRules([]));
}, []);
const { clashConfig, sysproxy, rules, uptime } = useAppData();
// 使用useMemo缓存格式化后的uptime避免频繁计算
const uptime = useMemo(() => formatUptime(uptimeMs), [uptimeMs]);
const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
// 使用备忘录组件内容,减少重新渲染
const cardContent = useMemo(() => {
if (!clashInfo) return null;
if (!clashConfig) return null;
return (
<Stack spacing={1.5}>
@ -76,7 +51,7 @@ export const ClashInfoCard = () => {
{t("Mixed Port")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashInfo.mixed_port || "-"}
{clashConfig["mixed-port"] || "-"}
</Typography>
</Stack>
<Divider />
@ -85,7 +60,7 @@ export const ClashInfoCard = () => {
{t("Uptime")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{uptime}
{formattedUptime}
</Typography>
</Stack>
<Divider />
@ -99,7 +74,7 @@ export const ClashInfoCard = () => {
</Stack>
</Stack>
);
}, [clashInfo, clashVersion, t, uptime, rules.length, sysproxy]);
}, [clashConfig, clashVersion, t, formattedUptime, rules.length, sysproxy]);
return (
<EnhancedCard

View File

@ -1,8 +1,7 @@
import { useTranslation } from "react-i18next";
import { Box, Typography, Paper, Stack, Fade } from "@mui/material";
import { useLockFn } from "ahooks";
import useSWR from "swr";
import { closeAllConnections, getClashConfig } from "@/services/api";
import { closeAllConnections } from "@/services/api";
import { patchClashMode } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import {
@ -11,22 +10,12 @@ import {
DirectionsRounded,
} from "@mui/icons-material";
import { useMemo } from "react";
import { useAppData } from "@/providers/app-data-provider";
export const ClashModeCard = () => {
const { t } = useTranslation();
const { verge } = useVerge();
// 获取当前Clash配置
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig,
{
revalidateOnFocus: false,
revalidateIfStale: true,
dedupingInterval: 1000,
errorRetryInterval: 5000
}
);
const { clashConfig, refreshProxy } = useAppData();
// 支持的模式列表
const modeList = useMemo(() => ["rule", "global", "direct"] as const, []);
@ -50,7 +39,8 @@ export const ClashModeCard = () => {
try {
await patchClashMode(mode);
mutateClash();
// 使用共享的刷新方法
refreshProxy();
} catch (error) {
console.error("Failed to change mode:", error);
}

View File

@ -13,7 +13,7 @@ import {
SelectChangeEvent,
Tooltip,
} from "@mui/material";
import { useEffect, useState, useMemo, useCallback, useRef } from "react";
import { useEffect, useState, useMemo, useCallback } from "react";
import {
SignalWifi4Bar as SignalStrong,
SignalWifi3Bar as SignalGood,
@ -24,16 +24,11 @@ import {
ChevronRight,
} from "@mui/icons-material";
import { useNavigate } from "react-router-dom";
import { useCurrentProxy } from "@/hooks/use-current-proxy";
import { EnhancedCard } from "@/components/home/enhanced-card";
import {
getProxies,
updateProxy,
getConnections,
deleteConnection,
} from "@/services/api";
import { updateProxy, deleteConnection } from "@/services/api";
import delayManager from "@/services/delay";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
// 本地存储的键名
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
@ -92,21 +87,16 @@ function debounce(fn: Function, ms = 100) {
export const CurrentProxyCard = () => {
const { t } = useTranslation();
const { currentProxy, primaryGroupName, mode, refreshProxy } =
useCurrentProxy();
const navigate = useNavigate();
const theme = useTheme();
const { verge } = useVerge();
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
// 判断模式
const mode = clashConfig?.mode?.toLowerCase() || "rule";
const isGlobalMode = mode === "global";
const isDirectMode = mode === "direct";
// 使用 useRef 存储最后一次刷新时间和是否正在刷新
const lastRefreshRef = useRef<number>(0);
const isRefreshingRef = useRef<boolean>(false);
const pendingRefreshRef = useRef<boolean>(false);
// 定义状态类型
type ProxyState = {
proxyData: {
@ -139,6 +129,32 @@ export const CurrentProxyCard = () => {
// 初始化选择的组
useEffect(() => {
if (!proxies) return;
// 提取primaryGroupName
const getPrimaryGroupName = () => {
if (!proxies?.groups?.length) return "";
// 查找主要的代理组(优先级:包含关键词 > 第一个非GLOBAL组
const primaryKeywords = [
"auto",
"select",
"proxy",
"节点选择",
"自动选择",
];
const primaryGroup =
proxies.groups.find((group: { name: string }) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) || proxies.groups.filter((g: { name: string }) => g.name !== "GLOBAL")[0];
return primaryGroup?.name || "";
};
const primaryGroupName = getPrimaryGroupName();
// 根据模式确定初始组
if (isGlobalMode) {
setState((prev) => ({
@ -166,148 +182,79 @@ export const CurrentProxyCard = () => {
},
}));
}
}, [isGlobalMode, isDirectMode, primaryGroupName]);
}, [isGlobalMode, isDirectMode, proxies]);
// 带锁的代理数据获取函数,防止并发请求
const fetchProxyData = useCallback(
async (force = false) => {
// 防止重复请求
if (isRefreshingRef.current) {
pendingRefreshRef.current = true;
return;
}
// 监听代理数据变化,更新状态
useEffect(() => {
if (!proxies) return;
// 使用函数式更新确保状态更新的原子性
setState((prev) => {
// 过滤和格式化组
const filteredGroups = proxies.groups
.filter((g: { name: string }) => g.name !== "DIRECT" && g.name !== "REJECT")
.map((g: { name: string; now: string; all: Array<{ name: string }> }) => ({
name: g.name,
now: g.now || "",
all: g.all.map((p: { name: string }) => p.name),
}));
let newProxy = "";
let newDisplayProxy = null;
let newGroup = prev.selection.group;
// 检查刷新间隔,强制增加最小间隔
const now = Date.now();
if (!force && now - lastRefreshRef.current < 1500) {
return;
}
// 根据模式确定新代理
if (isDirectMode) {
newGroup = "DIRECT";
newProxy = "DIRECT";
newDisplayProxy = proxies.records?.DIRECT || null;
} else if (isGlobalMode && proxies.global) {
newGroup = "GLOBAL";
newProxy = proxies.global.now || "";
newDisplayProxy = proxies.records?.[newProxy] || null;
} else {
// 普通模式 - 检查当前选择的组是否存在
const currentGroup = filteredGroups.find(
(g: { name: string }) => g.name === prev.selection.group,
);
isRefreshingRef.current = true;
lastRefreshRef.current = now;
// 如果当前组不存在或为空,自动选择第一个组
if (!currentGroup && filteredGroups.length > 0) {
newGroup = filteredGroups[0].name;
const firstGroup = filteredGroups[0];
newProxy = firstGroup.now;
newDisplayProxy = proxies.records?.[newProxy] || null;
try {
const data = await getProxies();
// 过滤和格式化组
const filteredGroups = data.groups
.filter((g) => g.name !== "DIRECT" && g.name !== "REJECT")
.map((g) => ({
name: g.name,
now: g.now || "",
all: g.all.map((p) => p.name),
}));
// 使用函数式更新确保状态更新的原子性
setState((prev) => {
let newProxy = "";
let newDisplayProxy = null;
let newGroup = prev.selection.group;
// 根据模式确定新代理
if (isDirectMode) {
newGroup = "DIRECT";
newProxy = "DIRECT";
newDisplayProxy = data.records?.DIRECT || null;
} else if (isGlobalMode && data.global) {
newGroup = "GLOBAL";
newProxy = data.global.now || "";
newDisplayProxy = data.records?.[newProxy] || null;
} else {
// 普通模式 - 检查当前选择的组是否存在
const currentGroup = filteredGroups.find(
(g) => g.name === prev.selection.group,
);
// 如果当前组不存在或为空,自动选择第一个组
if (!currentGroup && filteredGroups.length > 0) {
newGroup = filteredGroups[0].name;
const firstGroup = filteredGroups[0];
newProxy = firstGroup.now;
newDisplayProxy = data.records?.[newProxy] || null;
// 保存到本地存储
if (!isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
if (newProxy) {
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
}
}
} else if (currentGroup) {
// 使用当前组的代理
newProxy = currentGroup.now;
newDisplayProxy = data.records?.[newProxy] || null;
// 保存到本地存储
if (!isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
if (newProxy) {
localStorage.setItem(STORAGE_KEY_PROXY, newProxy);
}
}
// 返回新状态
return {
proxyData: {
groups: filteredGroups,
records: data.records || {},
globalProxy: data.global?.now || "",
directProxy: data.records?.DIRECT || null,
},
selection: {
group: newGroup,
proxy: newProxy,
},
displayProxy: newDisplayProxy,
};
});
} catch (error) {
console.error("获取代理信息失败", error);
} finally {
isRefreshingRef.current = false;
// 处理待处理的刷新请求,但增加延迟
if (pendingRefreshRef.current) {
pendingRefreshRef.current = false;
setTimeout(() => fetchProxyData(), 500);
} else if (currentGroup) {
// 使用当前组的代理
newProxy = currentGroup.now;
newDisplayProxy = proxies.records?.[newProxy] || null;
}
}
},
[isGlobalMode, isDirectMode],
);
// 响应 currentProxy 变化,增加时间检查避免循环调用
useEffect(() => {
if (
currentProxy &&
(!state.displayProxy ||
(currentProxy.name !== state.displayProxy.name &&
Date.now() - lastRefreshRef.current > 1000))
) {
fetchProxyData(true);
}
}, [currentProxy, fetchProxyData]);
// 监听模式变化mode变化时刷新
useEffect(() => {
fetchProxyData(true);
}, [mode, fetchProxyData]);
// 计算要显示的代理选项 - 使用 useMemo 优化
const proxyOptions = useMemo(() => {
if (isDirectMode) {
return [{ name: "DIRECT" }];
}
if (isGlobalMode && state.proxyData.records) {
// 全局模式下的选项
return Object.keys(state.proxyData.records)
.filter((name) => name !== "DIRECT" && name !== "REJECT")
.map((name) => ({ name }));
}
// 普通模式
const group = state.proxyData.groups.find(
(g) => g.name === state.selection.group,
);
if (group) {
return group.all.map((name) => ({ name }));
}
return [];
}, [isDirectMode, isGlobalMode, state.proxyData, state.selection.group]);
// 返回新状态
return {
proxyData: {
groups: filteredGroups,
records: proxies.records || {},
globalProxy: proxies.global?.now || "",
directProxy: proxies.records?.DIRECT || null,
},
selection: {
group: newGroup,
proxy: newProxy,
},
displayProxy: newDisplayProxy,
};
});
}, [proxies, isGlobalMode, isDirectMode]);
// 使用防抖包装状态更新,避免快速连续更新,增加防抖时间
const debouncedSetState = useCallback(
@ -329,7 +276,7 @@ export const CurrentProxyCard = () => {
// 获取该组当前选中的代理
setState((prev) => {
const group = prev.proxyData.groups.find((g) => g.name === newGroup);
const group = prev.proxyData.groups.find((g: { name: string }) => g.name === newGroup);
if (group) {
return {
...prev,
@ -382,20 +329,16 @@ export const CurrentProxyCard = () => {
// 自动关闭连接设置
if (verge?.auto_close_connection && previousProxy) {
getConnections().then(({ connections }) => {
connections.forEach((conn) => {
if (conn.chains.includes(previousProxy)) {
deleteConnection(conn.id);
}
});
connections.data.forEach((conn: any) => {
if (conn.chains.includes(previousProxy)) {
deleteConnection(conn.id);
}
});
}
// 延长刷新延迟时间
setTimeout(() => {
refreshProxy();
// 给refreshProxy一点时间完成再触发fetchProxyData
setTimeout(() => fetchProxyData(true), 300);
}, 500);
} catch (error) {
console.error("更新代理失败", error);
@ -408,8 +351,8 @@ export const CurrentProxyCard = () => {
state.selection,
verge?.auto_close_connection,
refreshProxy,
fetchProxyData,
debouncedSetState,
connections.data,
],
);
@ -419,11 +362,14 @@ export const CurrentProxyCard = () => {
}, [navigate]);
// 获取要显示的代理节点
const proxyToDisplay = state.displayProxy || currentProxy;
const currentProxy = useMemo(() => {
// 从state中获取当前代理信息
return state.displayProxy;
}, [state.displayProxy]);
// 获取当前节点的延迟
const currentDelay = proxyToDisplay
? delayManager.getDelayFix(proxyToDisplay, state.selection.group)
const currentDelay = currentProxy
? delayManager.getDelayFix(currentProxy, state.selection.group)
: -1;
// 获取信号图标
@ -453,23 +399,45 @@ export const CurrentProxyCard = () => {
[state.proxyData.records, state.selection.group],
);
// 计算要显示的代理选项 - 使用 useMemo 优化
const proxyOptions = useMemo(() => {
if (isDirectMode) {
return [{ name: "DIRECT" }];
}
if (isGlobalMode && state.proxyData.records) {
// 全局模式下的选项
return Object.keys(state.proxyData.records)
.filter((name) => name !== "DIRECT" && name !== "REJECT")
.map((name) => ({ name }));
}
// 普通模式
const group = state.proxyData.groups.find(
(g: { name: string }) => g.name === state.selection.group,
);
if (group) {
return group.all.map((name) => ({ name }));
}
return [];
}, [isDirectMode, isGlobalMode, state.proxyData, state.selection.group]);
return (
<EnhancedCard
title={t("Current Node")}
icon={
<Tooltip
title={
proxyToDisplay
currentProxy
? `${signalInfo.text}: ${delayManager.formatDelay(currentDelay)}`
: "无代理节点"
}
>
<Box sx={{ color: signalInfo.color }}>
{proxyToDisplay ? signalInfo.icon : <SignalNone color="disabled" />}
{currentProxy ? signalInfo.icon : <SignalNone color="disabled" />}
</Box>
</Tooltip>
}
iconColor={proxyToDisplay ? "primary" : undefined}
iconColor={currentProxy ? "primary" : undefined}
action={
<Button
variant="outlined"
@ -482,7 +450,7 @@ export const CurrentProxyCard = () => {
</Button>
}
>
{proxyToDisplay ? (
{currentProxy ? (
<Box>
{/* 代理节点信息显示 */}
<Box
@ -499,7 +467,7 @@ export const CurrentProxyCard = () => {
>
<Box>
<Typography variant="body1" fontWeight="medium">
{proxyToDisplay.name}
{currentProxy.name}
</Typography>
<Box
@ -510,7 +478,7 @@ export const CurrentProxyCard = () => {
color="text.secondary"
sx={{ mr: 1 }}
>
{proxyToDisplay.type}
{currentProxy.type}
</Typography>
{isGlobalMode && (
<Chip
@ -529,26 +497,26 @@ export const CurrentProxyCard = () => {
/>
)}
{/* 节点特性 */}
{proxyToDisplay.udp && (
{currentProxy.udp && (
<Chip size="small" label="UDP" variant="outlined" />
)}
{proxyToDisplay.tfo && (
{currentProxy.tfo && (
<Chip size="small" label="TFO" variant="outlined" />
)}
{proxyToDisplay.xudp && (
{currentProxy.xudp && (
<Chip size="small" label="XUDP" variant="outlined" />
)}
{proxyToDisplay.mptcp && (
{currentProxy.mptcp && (
<Chip size="small" label="MPTCP" variant="outlined" />
)}
{proxyToDisplay.smux && (
{currentProxy.smux && (
<Chip size="small" label="SMUX" variant="outlined" />
)}
</Box>
</Box>
{/* 显示延迟 */}
{proxyToDisplay && !isDirectMode && (
{currentProxy && !isDirectMode && (
<Chip
size="small"
label={delayManager.formatDelay(currentDelay)}

View File

@ -28,6 +28,7 @@ import { createAuthSockette } from "@/utils/websocket";
import parseTraffic from "@/utils/parse-traffic";
import { getConnections, isDebugEnabled, gc } from "@/services/api";
import { ReactNode } from "react";
import { useAppData } from "@/providers/app-data-provider";
interface MemoryUsage {
inuse: number;
@ -157,11 +158,13 @@ export const EnhancedTrafficStats = () => {
const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
// 使用AppDataProvider
const { connections, uptime } = useAppData();
// 使用单一状态对象减少状态更新次数
const [stats, setStats] = useState({
traffic: { up: 0, down: 0 },
memory: { inuse: 0, oslimit: undefined as number | undefined },
connections: { uploadTotal: 0, downloadTotal: 0, activeConnections: 0 },
});
// 创建一个标记来追踪最后更新时间,用于节流
@ -176,36 +179,6 @@ export const EnhancedTrafficStats = () => {
memory: null as ReturnType<typeof createAuthSockette> | null,
});
// 获取连接数据
const fetchConnections = useCallback(async () => {
if (!pageVisible) return;
try {
const connections = await getConnections();
if (connections) {
setStats(prev => ({
...prev,
connections: {
uploadTotal: connections.uploadTotal || 0,
downloadTotal: connections.downloadTotal || 0,
activeConnections: connections.connections ? connections.connections.length : 0,
}
}));
}
} catch (err) {
console.error("Failed to fetch connections:", err);
}
}, [pageVisible]);
// 定期更新连接数据
useEffect(() => {
if (!pageVisible) return;
fetchConnections();
const intervalId = setInterval(fetchConnections, CONNECTIONS_UPDATE_INTERVAL);
return () => clearInterval(intervalId);
}, [pageVisible, fetchConnections]);
// 检查是否支持调试
useEffect(() => {
isDebugEnabled().then((flag) => setIsDebug(flag));
@ -328,14 +301,14 @@ export const EnhancedTrafficStats = () => {
const [up, upUnit] = parseTraffic(stats.traffic.up);
const [down, downUnit] = parseTraffic(stats.traffic.down);
const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse);
const [uploadTotal, uploadTotalUnit] = parseTraffic(stats.connections.uploadTotal);
const [downloadTotal, downloadTotalUnit] = parseTraffic(stats.connections.downloadTotal);
const [uploadTotal, uploadTotalUnit] = parseTraffic(connections.uploadTotal);
const [downloadTotal, downloadTotalUnit] = parseTraffic(connections.downloadTotal);
return {
up, upUnit, down, downUnit, inuse, inuseUnit,
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit
};
}, [stats]);
}, [stats, connections.uploadTotal, connections.downloadTotal]);
// 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => {
@ -398,7 +371,7 @@ export const EnhancedTrafficStats = () => {
{
icon: <LinkRounded fontSize="small" />,
title: t("Active Connections"),
value: stats.connections.activeConnections,
value: connections.count,
unit: "",
color: "success" as const,
},
@ -424,7 +397,7 @@ export const EnhancedTrafficStats = () => {
color: "error" as const,
onClick: isDebug ? handleGarbageCollection : undefined,
},
], [t, parsedData, stats.connections.activeConnections, isDebug, handleGarbageCollection]);
], [t, parsedData, connections.count, isDebug, handleGarbageCollection]);
return (
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>

View File

@ -27,6 +27,7 @@ import { openWebUrl, updateProfile } from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Notice } from "@/components/base";
import { EnhancedCard } from "./enhanced-card";
import { useAppData } from "@/providers/app-data-provider";
// 定义旋转动画
const round = keyframes`
@ -270,6 +271,7 @@ const EmptyProfile = ({ onClick }: { onClick: () => void }) => {
export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { refreshAll } = useAppData();
// 更新当前订阅
const [updating, setUpdating] = useState(false);
@ -282,6 +284,9 @@ export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardPr
await updateProfile(current.uid);
Notice.success(t("Update subscription successfully"));
onProfileUpdated?.();
// 刷新首页数据
refreshAll();
} catch (err: any) {
Notice.error(err?.message || err.toString());
} finally {

View File

@ -18,13 +18,8 @@ import {
HelpOutlineRounded,
SvgIconComponent,
} from "@mui/icons-material";
import useSWR from "swr";
import {
getSystemProxy,
getAutotemProxy,
getRunningMode,
} from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
const LOCAL_STORAGE_TAB_KEY = "clash-verge-proxy-active-tab";
@ -150,8 +145,7 @@ export const ProxyTunCard: FC = () => {
);
// 获取代理状态信息
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: runningMode } = useSWR("getRunningMode", getRunningMode);
const { sysproxy, runningMode } = useAppData();
const { verge } = useVerge();
// 从verge配置中获取开关状态

View File

@ -1,195 +1,46 @@
import dayjs from "dayjs";
import useSWR, { mutate } from "swr";
import { useState } from "react";
import {
import {
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
List,
ListItem,
ListItemText,
styled,
Box,
alpha,
Typography,
Divider,
LinearProgress,
keyframes,
alpha,
styled,
useTheme
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { getProxyProviders, proxyProviderUpdate } from "@/services/api";
import { BaseDialog } from "../base";
import { useLockFn } from "ahooks";
import { proxyProviderUpdate } from "@/services/api";
import { useAppData } from "@/providers/app-data-provider";
import { Notice } from "@/components/base";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import dayjs from "dayjs";
import parseTraffic from "@/utils/parse-traffic";
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
export const ProviderButton = () => {
const { t } = useTranslation();
const { data } = useSWR("getProxyProviders", getProxyProviders);
const [open, setOpen] = useState(false);
const hasProvider = Object.keys(data || {}).length > 0;
const [updating, setUpdating] = useState(
Object.keys(data || {}).map(() => false),
);
const setUpdatingAt = (status: boolean, index: number) => {
setUpdating((prev) => {
const next = [...prev];
next[index] = status;
return next;
});
};
const handleUpdate = async (key: string, index: number) => {
setUpdatingAt(true, index);
proxyProviderUpdate(key)
.then(async () => {
setUpdatingAt(false, index);
await mutate("getProxies");
await mutate("getProxyProviders");
})
.catch(async () => {
setUpdatingAt(false, index);
await mutate("getProxies");
await mutate("getProxyProviders");
});
// 定义代理提供者类型
interface ProxyProviderItem {
name?: string;
proxies: any[];
updatedAt: number;
vehicleType: string;
subscriptionInfo?: {
Upload: number;
Download: number;
Total: number;
Expire: number;
};
}
if (!hasProvider) return null;
return (
<>
<Button
size="small"
variant="outlined"
sx={{ textTransform: "capitalize" }}
onClick={() => setOpen(true)}
>
{t("Proxy Provider")}
</Button>
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between" gap={1}>
<Typography variant="h6">{t("Proxy Provider")}</Typography>
<Button
variant="contained"
size="small"
onClick={async () => {
Object.entries(data || {}).forEach(async ([key], index) => {
await handleUpdate(key, index);
});
}}
>
{t("Update All")}
</Button>
</Box>
}
contentSx={{ width: 400 }}
disableOk
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(data || {}).map(([key, item], index) => {
const time = dayjs(item.updatedAt);
const sub = item.subscriptionInfo;
const hasSubInfo = !!sub;
const upload = sub?.Upload || 0;
const download = sub?.Download || 0;
const total = sub?.Total || 0;
const expire = sub?.Expire || 0;
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100,
);
return (
<>
<ListItem
sx={{
p: 0,
borderRadius: "10px",
border: "solid 2px var(--divider-color)",
mb: 1,
}}
key={key}
>
<ListItemText
sx={{ px: 1 }}
primary={
<>
<Typography
variant="h6"
component="span"
noWrap
title={key}
>
{key}
</Typography>
<TypeBox component="span" sx={{ marginLeft: "8px" }}>
{item.proxies.length}
</TypeBox>
</>
}
secondary={
<>
<StyledTypeBox component="span">
{item.vehicleType}
</StyledTypeBox>
<StyledTypeBox component="span">
{t("Update At")} {time.fromNow()}
</StyledTypeBox>
{hasSubInfo && (
<>
<Box sx={{ ...boxStyle, fontSize: 14 }}>
<span title="Used / Total">
{parseTraffic(upload + download)} /{" "}
{parseTraffic(total)}
</span>
<span title="Expire Time">
{parseExpire(expire)}
</span>
</Box>
<LinearProgress
variant="determinate"
value={progress}
style={{ opacity: total > 0 ? 1 : 0 }}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<IconButton
size="small"
color="inherit"
title={`${t("Update")}${t("Proxy Provider")}`}
onClick={() => handleUpdate(key, index)}
sx={{
animation: updating[index]
? `1s linear infinite ${round}`
: "none",
}}
>
<RefreshRounded />
</IconButton>
</ListItem>
</>
);
})}
</List>
</BaseDialog>
</>
);
};
// 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
@ -202,28 +53,272 @@ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
lineHeight: 1.25,
}));
const StyledTypeBox = styled(Box)<{ component?: React.ElementType }>(
({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}),
);
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
};
function parseExpire(expire?: number) {
// 解析过期时间
const parseExpire = (expire?: number) => {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}
};
export const ProviderButton = () => {
const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false);
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者
const hasProviders = Object.keys(proxyProviders || {}).length > 0;
// 更新单个代理提供者
const updateProvider = useLockFn(async (name: string) => {
try {
// 设置更新状态
setUpdating(prev => ({ ...prev, [name]: true }));
await proxyProviderUpdate(name);
// 刷新数据
await refreshProxy();
await refreshProxyProviders();
Notice.success(`${name} 更新成功`);
} catch (err: any) {
Notice.error(`${name} 更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
}
});
// 更新所有代理提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(proxyProviders || {});
if (allProviders.length === 0) {
Notice.info("没有可更新的代理提供者");
return;
}
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as Record<string, boolean>);
setUpdating(newUpdating);
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await proxyProviderUpdate(name);
// 每个更新完成后更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
} catch (err) {
console.error(`更新 ${name} 失败`, err);
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshProxy();
await refreshProxyProviders();
Notice.success("全部代理提供者更新成功");
} catch (err: any) {
Notice.error(`更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
}
});
const handleClose = () => {
setOpen(false);
};
if (!hasProviders) return null;
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<StorageOutlined />}
onClick={() => setOpen(true)}
sx={{ mr: 1 }}
>
{t("Proxy Provider")}
</Button>
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">{t("Proxy Providers")}</Typography>
<Box>
<Button
variant="contained"
size="small"
onClick={updateAllProviders}
>
{t("Update All")}
</Button>
</Box>
</Box>
</DialogTitle>
<DialogContent>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(proxyProviders || {}).map(([key, item]) => {
const provider = item as ProxyProviderItem;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
// 订阅信息
const sub = provider.subscriptionInfo;
const hasSubInfo = !!sub;
const upload = sub?.Upload || 0;
const download = sub?.Download || 0;
const total = sub?.Total || 0;
const expire = sub?.Expire || 0;
// 流量使用进度
const progress = total > 0
? Math.min(Math.round(((download + upload) * 100) / total) + 1, 100)
: 0;
return (
<ListItem
key={key}
sx={[
{
p: 0,
mb: "8px",
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s"
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const hoverColor = mode === "light"
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return {
backgroundColor: bgcolor,
"&:hover": {
backgroundColor: hoverColor,
borderColor: alpha(primary.main, 0.3)
}
};
}
]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.proxies.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
<small>{t("Update At")}: </small>{time.fromNow()}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}>
<span title={t("Used / Total") as string}>
{parseTraffic(upload + download)} / {parseTraffic(total)}
</span>
<span title={t("Expire Time") as string}>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<IconButton
size="small"
color="primary"
onClick={(e) => {
updateProvider(key);
}}
disabled={isUpdating}
sx={{
animation: isUpdating ? "spin 1s linear infinite" : "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" }
}
}}
title={t("Update Provider") as string}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
);
})}
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@ -25,10 +25,10 @@ import { downloadIconCache } from "@/services/cmds";
interface RenderProps {
item: IRenderItem;
indent: boolean;
onLocation: (group: IProxyGroupItem) => void;
onLocation: (group: IRenderItem["group"]) => void;
onCheckAll: (groupName: string) => void;
onHeadState: (groupName: string, patch: Partial<HeadState>) => void;
onChangeProxy: (group: IProxyGroupItem, proxy: IProxyItem) => void;
onChangeProxy: (group: IRenderItem["group"], proxy: IRenderItem["proxy"] & { name: string }) => void;
}
export const ProxyRender = (props: RenderProps) => {

View File

@ -1,6 +1,4 @@
import useSWR from "swr";
import { useEffect, useMemo, useCallback } from "react";
import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge";
import { filterSort } from "./use-filter-sort";
import { useWindowWidth } from "./use-window-width";
@ -9,12 +7,52 @@ import {
DEFAULT_STATE,
type HeadState,
} from "./use-head-state";
import { useAppData } from "@/providers/app-data-provider";
// 定义代理项接口
interface IProxyItem {
name: string;
type: string;
udp: boolean;
xudp: boolean;
tfo: boolean;
mptcp: boolean;
smux: boolean;
history: {
time: string;
delay: number;
}[];
provider?: string;
testUrl?: string;
[key: string]: any; // 添加索引签名以适应其他可能的属性
}
// 代理组类型
type ProxyGroup = {
name: string;
type: string;
udp: boolean;
xudp: boolean;
tfo: boolean;
mptcp: boolean;
smux: boolean;
history: {
time: string;
delay: number;
}[];
now: string;
all: IProxyItem[];
hidden?: boolean;
icon?: string;
testUrl?: string;
provider?: string;
};
export interface IRenderItem {
// 组 | head | item | empty | item col
type: 0 | 1 | 2 | 3 | 4;
key: string;
group: IProxyGroupItem;
group: ProxyGroup;
proxy?: IProxyItem;
col?: number;
proxyCol?: IProxyItem[];
@ -51,16 +89,8 @@ const groupProxies = <T = any>(list: T[], size: number): T[][] => {
};
export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
getProxies,
{
refreshInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
);
// 使用全局数据提供者
const { proxies: proxiesData, refreshProxy } = useAppData();
const { verge } = useVerge();
const { width } = useWindowWidth();
const [headStates, setHeadState] = useHeadStateNew();
@ -80,9 +110,9 @@ export const useRenderList = (mode: string) => {
(mode === "rule" && !groups.length) ||
(mode === "global" && proxies.length < 2)
) {
setTimeout(() => mutateProxies(), 500);
setTimeout(() => refreshProxy(), 500);
}
}, [proxiesData, mode, mutateProxies]);
}, [proxiesData, mode, refreshProxy]);
// 处理渲染列表
const renderList: IRenderItem[] = useMemo(() => {
@ -94,7 +124,7 @@ export const useRenderList = (mode: string) => {
? proxiesData.groups
: [proxiesData.global!];
const retList = renderGroups.flatMap((group) => {
const retList = renderGroups.flatMap((group: ProxyGroup) => {
const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [
{
@ -158,12 +188,12 @@ export const useRenderList = (mode: string) => {
});
if (!useRule) return retList.slice(1);
return retList.filter((item) => !item.group.hidden);
return retList.filter((item: IRenderItem) => !item.group.hidden);
}, [headStates, proxiesData, mode, col]);
return {
renderList,
onProxies: mutateProxies,
onProxies: refreshProxy,
onHeadState: setHeadState,
currentColumns: col,
};

View File

@ -1,170 +1,39 @@
import dayjs from "dayjs";
import useSWR, { mutate } from "swr";
import { useState } from "react";
import {
Button,
import {
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
List,
ListItem,
ListItemText,
Typography,
styled,
Box,
alpha,
Divider,
keyframes,
alpha,
styled,
useTheme
} from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { getRuleProviders, ruleProviderUpdate } from "@/services/api";
import { BaseDialog } from "../base";
import { useLockFn } from "ahooks";
import { ruleProviderUpdate } from "@/services/api";
import { Notice } from "@/components/base";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import { useAppData } from "@/providers/app-data-provider";
import dayjs from "dayjs";
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
// 定义规则提供者类型
interface RuleProviderItem {
behavior: string;
ruleCount: number;
updatedAt: number;
vehicleType: string;
}
export const ProviderButton = () => {
const { t } = useTranslation();
const { data } = useSWR("getRuleProviders", getRuleProviders);
const [open, setOpen] = useState(false);
const hasProvider = Object.keys(data || {}).length > 0;
const [updating, setUpdating] = useState(
Object.keys(data || {}).map(() => false),
);
const setUpdatingAt = (status: boolean, index: number) => {
setUpdating((prev) => {
const next = [...prev];
next[index] = status;
return next;
});
};
const handleUpdate = async (key: string, index: number) => {
setUpdatingAt(true, index);
ruleProviderUpdate(key)
.then(async () => {
setUpdatingAt(false, index);
await mutate("getRules");
await mutate("getRuleProviders");
})
.catch(async () => {
setUpdatingAt(false, index);
await mutate("getRules");
await mutate("getRuleProviders");
});
};
if (!hasProvider) return null;
return (
<>
<Button
size="small"
variant="outlined"
sx={{ textTransform: "capitalize" }}
onClick={() => setOpen(true)}
>
{t("Rule Provider")}
</Button>
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between" gap={1}>
<Typography variant="h6">{t("Rule Provider")}</Typography>
<Button
variant="contained"
size="small"
onClick={async () => {
Object.entries(data || {}).forEach(async ([key], index) => {
await handleUpdate(key, index);
});
}}
>
{t("Update All")}
</Button>
</Box>
}
contentSx={{ width: 400 }}
disableOk
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(data || {}).map(([key, item], index) => {
const time = dayjs(item.updatedAt);
return (
<>
<ListItem
sx={{
p: 0,
borderRadius: "10px",
border: "solid 2px var(--divider-color)",
mb: 1,
}}
key={key}
>
<ListItemText
sx={{ px: 1 }}
primary={
<>
<Typography
variant="h6"
component="span"
noWrap
title={key}
>
{key}
</Typography>
<TypeBox component="span" sx={{ marginLeft: "8px" }}>
{item.ruleCount}
</TypeBox>
</>
}
secondary={
<>
<StyledTypeBox component="span">
{item.vehicleType}
</StyledTypeBox>
<StyledTypeBox component="span">
{item.behavior}
</StyledTypeBox>
<StyledTypeBox component="span">
{t("Update At")} {time.fromNow()}
</StyledTypeBox>
</>
}
/>
<Divider orientation="vertical" flexItem />
<IconButton
size="small"
color="inherit"
title={`${t("Update")}${t("Rule Provider")}`}
onClick={() => handleUpdate(key, index)}
sx={{
animation: updating[index]
? `1s linear infinite ${round}`
: "none",
}}
>
<RefreshRounded />
</IconButton>
</ListItem>
</>
);
})}
</List>
</BaseDialog>
</>
);
};
const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme }) => ({
// 辅助组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5),
@ -176,16 +45,222 @@ const TypeBox = styled(Box, {
lineHeight: 1.25,
}));
const StyledTypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
export const ProviderButton = () => {
const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false);
const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者
const hasProviders = Object.keys(ruleProviders || {}).length > 0;
// 更新单个规则提供者
const updateProvider = useLockFn(async (name: string) => {
try {
// 设置更新状态
setUpdating(prev => ({ ...prev, [name]: true }));
await ruleProviderUpdate(name);
// 刷新数据
await refreshRules();
await refreshRuleProviders();
Notice.success(`${name} 更新成功`);
} catch (err: any) {
Notice.error(`${name} 更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
}
});
// 更新所有规则提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(ruleProviders || {});
if (allProviders.length === 0) {
Notice.info("没有可更新的规则提供者");
return;
}
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as Record<string, boolean>);
setUpdating(newUpdating);
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await ruleProviderUpdate(name);
// 每个更新完成后更新状态
setUpdating(prev => ({ ...prev, [name]: false }));
} catch (err) {
console.error(`更新 ${name} 失败`, err);
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshRules();
await refreshRuleProviders();
Notice.success("全部规则提供者更新成功");
} catch (err: any) {
Notice.error(`更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
}
});
const handleClose = () => {
setOpen(false);
};
if (!hasProviders) return null;
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<StorageOutlined />}
onClick={() => setOpen(true)}
>
{t("Rule Provider")}
</Button>
<Dialog
open={open}
onClose={handleClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6">{t("Rule Providers")}</Typography>
<Button
variant="contained"
size="small"
onClick={updateAllProviders}
>
{t("Update All")}
</Button>
</Box>
</DialogTitle>
<DialogContent>
<List sx={{ py: 0, minHeight: 250 }}>
{Object.entries(ruleProviders || {}).map(([key, item]) => {
const provider = item as RuleProviderItem;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
return (
<ListItem
key={key}
sx={[
{
p: 0,
mb: "8px",
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s"
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const hoverColor = mode === "light"
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return {
backgroundColor: bgcolor,
"&:hover": {
backgroundColor: hoverColor,
borderColor: alpha(primary.main, 0.3)
}
};
}
]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.ruleCount}
</TypeBox>
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
<small>{t("Update At")}: </small>{time.fromNow()}
</Typography>
</Box>
}
secondary={
<Box sx={{ display: "flex" }}>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
<TypeBox component="span">
{provider.behavior}
</TypeBox>
</Box>
}
/>
<Divider orientation="vertical" flexItem />
<Box sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center"
}}>
<IconButton
size="small"
color="primary"
onClick={() => updateProvider(key)}
disabled={isUpdating}
sx={{
animation: isUpdating ? "spin 1s linear infinite" : "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" }
}
}}
title={t("Update Provider") as string}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
);
})}
</List>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@ -1,32 +1,25 @@
import useSWR from "swr";
import { useMemo } from "react";
import { getProxies } from "@/services/api";
import { getClashConfig } from "@/services/api";
import { useAppData } from "@/providers/app-data-provider";
// 定义代理组类型
interface ProxyGroup {
name: string;
now: string;
}
// 获取当前代理节点信息的自定义Hook
export const useCurrentProxy = () => {
// 获取代理信息
const { data: proxiesData, mutate: mutateProxies } = useSWR(
"getProxies",
getProxies,
{
refreshInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
);
// 获取当前Clash配置包含模式信息
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
// 从AppDataProvider获取数据
const { proxies, clashConfig, refreshProxy } = useAppData();
// 获取当前模式
const currentMode = clashConfig?.mode?.toLowerCase() || "rule";
// 获取当前代理节点信息
const currentProxyInfo = useMemo(() => {
if (!proxiesData) return { currentProxy: null, primaryGroupName: null };
if (!proxies) return { currentProxy: null, primaryGroupName: null };
const { global, groups, records } = proxiesData;
const { global, groups, records } = proxies;
// 默认信息
let primaryGroupName = "GLOBAL";
@ -43,11 +36,11 @@ export const useCurrentProxy = () => {
"自动选择",
];
const primaryGroup =
groups.find((group) =>
groups.find((group: ProxyGroup) =>
primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()),
),
) || groups.filter((g) => g.name !== "GLOBAL")[0];
) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0];
if (primaryGroup) {
primaryGroupName = primaryGroup.name;
@ -71,12 +64,12 @@ export const useCurrentProxy = () => {
};
return { currentProxy, primaryGroupName };
}, [proxiesData, currentMode]);
}, [proxies, currentMode]);
return {
currentProxy: currentProxyInfo.currentProxy,
primaryGroupName: currentProxyInfo.primaryGroupName,
mode: currentMode,
refreshProxy: mutateProxies,
refreshProxy,
};
};

View File

@ -19,6 +19,7 @@ import {
ThemeModeProvider,
UpdateStateProvider,
} from "./services/states";
import { AppDataProvider } from "./providers/app-data-provider";
const mainElementId = "root";
const container = document.getElementById(mainElementId);
@ -51,9 +52,11 @@ createRoot(container).render(
<React.StrictMode>
<ComposeContextProvider contexts={contexts}>
<BaseErrorBoundary>
<BrowserRouter>
<Layout />
</BrowserRouter>
<AppDataProvider>
<BrowserRouter>
<Layout />
</BrowserRouter>
</AppDataProvider>
</BaseErrorBoundary>
</ComposeContextProvider>
</React.StrictMode>

View File

@ -11,7 +11,6 @@ import {
} from "@mui/icons-material";
import { closeAllConnections } from "@/services/api";
import { useConnectionSetting } from "@/services/states";
import { useClashInfo } from "@/hooks/use-clash";
import { BaseEmpty, BasePage } from "@/components/base";
import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table";
@ -25,10 +24,9 @@ import {
type SearchState,
} from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select";
import useSWRSubscription from "swr/subscription";
import { createSockette, createAuthSockette } from "@/utils/websocket";
import { useTheme } from "@mui/material/styles";
import { useVisibility } from "@/hooks/use-visibility";
import { useAppData } from "@/providers/app-data-provider";
const initConn: IConnections = {
uploadTotal: 0,
@ -40,12 +38,14 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const pageVisible = useVisibility();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [match, setMatch] = useState(() => (_: string) => true);
const [curOrderOpt, setOrderOpt] = useState("Default");
// 使用全局数据
const { connections } = useAppData();
const [setting, setSetting] = useConnectionSetting();
@ -66,99 +66,37 @@ const ConnectionsPage = () => {
const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState<IConnections | null>(null);
const { data: connData = initConn } = useSWRSubscription<
IConnections,
any,
"getClashConnections" | null
>(
clashInfo && pageVisible ? "getClashConnections" : null,
(_key, { next }) => {
const { server = "", secret = "" } = clashInfo!;
if (!server) {
console.warn("[Connections] 服务器地址为空,无法建立连接");
next(null, initConn);
return () => {};
}
console.log(`[Connections] 正在连接: ${server}/connections`);
// 设置较长的超时时间,确保连接可以建立
const s = createAuthSockette(`${server}/connections`, secret, {
timeout: 8000, // 8秒超时
onmessage(event) {
const data = JSON.parse(event.data) as IConnections;
next(null, (old = initConn) => {
const oldConn = old.connections;
const maxLen = data.connections?.length;
const connections: IConnectionsItem[] = [];
const rest = (data.connections || []).filter((each) => {
const index = oldConn.findIndex((o) => o.id === each.id);
if (index >= 0 && index < maxLen) {
const old = oldConn[index];
each.curUpload = each.upload - old.upload;
each.curDownload = each.download - old.download;
connections[index] = each;
return false;
}
return true;
});
for (let i = 0; i < maxLen; ++i) {
if (!connections[i] && rest.length > 0) {
connections[i] = rest.shift()!;
connections[i].curUpload = 0;
connections[i].curDownload = 0;
}
}
return { ...data, connections };
});
},
onerror(event) {
console.error("[Connections] WebSocket 连接错误", event);
// 报告错误但提供空数据避免UI崩溃
next(null, initConn);
},
onclose(event) {
console.log("[Connections] WebSocket 连接关闭", event);
},
onopen(event) {
console.log("[Connections] WebSocket 连接已建立");
},
});
return () => {
console.log("[Connections] 清理WebSocket连接");
try {
s.close();
} catch (e) {
console.error("[Connections] 关闭连接时出错", e);
}
};
},
);
// 使用全局连接数据
const displayData = useMemo(() => {
return isPaused ? (frozenData ?? connData) : connData;
}, [isPaused, frozenData, connData]);
if (!pageVisible) return initConn;
if (isPaused) {
return frozenData ?? {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data
};
}
return {
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data
};
}, [isPaused, frozenData, connections, pageVisible]);
const [filterConn] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt];
let connections = displayData.connections.filter((conn) => {
let conns = displayData.connections.filter((conn) => {
const { host, destinationIP, process } = conn.metadata;
return (
match(host || "") || match(destinationIP || "") || match(process || "")
);
});
if (orderFunc) connections = orderFunc(connections);
if (orderFunc) conns = orderFunc(conns);
return [connections];
return [conns];
}, [displayData, match, curOrderOpt]);
const onCloseAll = useLockFn(closeAllConnections);
@ -172,13 +110,17 @@ const ConnectionsPage = () => {
const handlePauseToggle = useCallback(() => {
setIsPaused((prev) => {
if (!prev) {
setFrozenData(connData);
setFrozenData({
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data
});
} else {
setFrozenData(null);
}
return !prev;
});
}, [connData]);
}, [connections]);
return (
<BasePage

View File

@ -203,7 +203,7 @@ const HomeSettingsDialog = ({
);
};
const HomePage = () => {
export const HomePage = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { current, mutateProfiles } = useProfiles();
@ -395,4 +395,4 @@ const ClashModeEnhancedCard = () => {
);
};
export default HomePage;
export default HomePage;

View File

@ -1,28 +1,27 @@
import useSWR from "swr";
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
import { Box } from "@mui/material";
import { getRules } from "@/services/api";
import { BaseEmpty, BasePage } from "@/components/base";
import RuleItem from "@/components/rule/rule-item";
import { ProviderButton } from "@/components/rule/provider-button";
import { BaseSearchBox } from "@/components/base/base-search-box";
import { useTheme } from "@mui/material/styles";
import { ScrollTopButton } from "@/components/layout/scroll-top-button";
import { useAppData } from "@/providers/app-data-provider";
const RulesPage = () => {
const { t } = useTranslation();
const { data = [] } = useSWR("getRules", getRules);
const { rules = [] } = useAppData();
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const [match, setMatch] = useState(() => (_: string) => true);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
const rules = useMemo(() => {
return data.filter((item) => match(item.payload));
}, [data, match]);
const filteredRules = useMemo(() => {
return rules.filter((item) => match(item.payload));
}, [rules, match]);
const scrollToTop = () => {
virtuosoRef.current?.scrollTo({
@ -64,11 +63,11 @@ const RulesPage = () => {
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
</Box>
{rules.length > 0 ? (
{filteredRules.length > 0 ? (
<>
<Virtuoso
ref={virtuosoRef}
data={rules}
data={filteredRules}
style={{
flex: 1,
}}

View File

@ -0,0 +1,299 @@
import { createContext, useContext, useMemo } from "react";
import useSWR from "swr";
import useSWRSubscription from "swr/subscription";
import { getProxies, getConnections, getRules, getClashConfig, getProxyProviders, getRuleProviders } from "@/services/api";
import { getSystemProxy, getRunningMode, getAppUptime } from "@/services/cmds";
import { useClashInfo } from "@/hooks/use-clash";
import { createAuthSockette } from "@/utils/websocket";
import { useVisibility } from "@/hooks/use-visibility";
// 定义AppDataContext类型 - 使用宽松类型
interface AppDataContextType {
proxies: any;
clashConfig: any;
rules: any[];
sysproxy: any;
runningMode?: string;
uptime: number;
proxyProviders: any;
ruleProviders: any;
connections: {
data: any[];
count: number;
uploadTotal: number;
downloadTotal: number;
};
traffic: {up: number; down: number};
memory: {inuse: number};
refreshProxy: () => Promise<any>;
refreshClashConfig: () => Promise<any>;
refreshRules: () => Promise<any>;
refreshSysproxy: () => Promise<any>;
refreshProxyProviders: () => Promise<any>;
refreshRuleProviders: () => Promise<any>;
refreshAll: () => Promise<any>;
}
// 创建上下文
const AppDataContext = createContext<AppDataContextType | null>(null);
// 全局数据提供者组件
export const AppDataProvider = ({ children }: { children: React.ReactNode }) => {
const { clashInfo } = useClashInfo();
const pageVisible = useVisibility();
// 基础数据 - 中频率更新 (5秒)
const { data: proxiesData, mutate: refreshProxy } = useSWR(
"getProxies",
getProxies,
{
refreshInterval: 5000,
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
const { data: clashConfig, mutate: refreshClashConfig } = useSWR(
"getClashConfig",
getClashConfig,
{
refreshInterval: 5000,
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
// 提供者数据
const { data: proxyProviders, mutate: refreshProxyProviders } = useSWR(
"getProxyProviders",
getProxyProviders,
{
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
const { data: ruleProviders, mutate: refreshRuleProviders } = useSWR(
"getRuleProviders",
getRuleProviders,
{
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
// 低频率更新数据
const { data: rulesData, mutate: refreshRules } = useSWR(
"getRules",
getRules,
{
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
const { data: sysproxy, mutate: refreshSysproxy } = useSWR(
"getSystemProxy",
getSystemProxy,
{
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
const { data: runningMode } = useSWR(
"getRunningMode",
getRunningMode,
{
revalidateOnFocus: false,
suspense: false,
errorRetryCount: 3
}
);
// 高频率更新数据 (1秒)
const { data: uptimeData } = useSWR(
"appUptime",
getAppUptime,
{
refreshInterval: 1000,
revalidateOnFocus: false,
suspense: false
}
);
// 连接数据 - 使用WebSocket实时更新
const { data: connectionsData = { connections: [], uploadTotal: 0, downloadTotal: 0 } } =
useSWRSubscription(
clashInfo && pageVisible ? "connections" : null,
(_key, { next }) => {
if (!clashInfo || !pageVisible) return () => {};
const { server = "", secret = "" } = clashInfo;
if (!server) return () => {};
const socket = createAuthSockette(`${server}/connections`, secret, {
timeout: 5000,
onmessage(event) {
try {
const data = JSON.parse(event.data);
// 处理连接数据,计算当前上传下载速度
next(null, (prev: any = { connections: [], uploadTotal: 0, downloadTotal: 0 }) => {
const oldConns = prev.connections || [];
const newConns = data.connections || [];
// 计算当前速度
const processedConns = newConns.map((conn: any) => {
const oldConn = oldConns.find((old: any) => old.id === conn.id);
if (oldConn) {
return {
...conn,
curUpload: conn.upload - oldConn.upload,
curDownload: conn.download - oldConn.download
};
}
return { ...conn, curUpload: 0, curDownload: 0 };
});
return {
...data,
connections: processedConns
};
});
} catch (err) {
console.error("[Connections] 解析数据错误:", err);
}
},
onerror() {
next(null, { connections: [], uploadTotal: 0, downloadTotal: 0 });
}
});
return () => socket.close();
}
);
// 流量和内存数据 - 通过WebSocket获取实时流量数据
const { data: trafficData = { up: 0, down: 0 } } = useSWRSubscription(
clashInfo && pageVisible ? "traffic" : null,
(_key, { next }) => {
if (!clashInfo || !pageVisible) return () => {};
const { server = "", secret = "" } = clashInfo;
if (!server) return () => {};
const socket = createAuthSockette(`${server}/traffic`, secret, {
onmessage(event) {
try {
const data = JSON.parse(event.data);
next(null, data);
} catch (err) {
console.error("[Traffic] 解析数据错误:", err);
}
}
});
return () => socket.close();
}
);
const { data: memoryData = { inuse: 0 } } = useSWRSubscription(
clashInfo && pageVisible ? "memory" : null,
(_key, { next }) => {
if (!clashInfo || !pageVisible) return () => {};
const { server = "", secret = "" } = clashInfo;
if (!server) return () => {};
const socket = createAuthSockette(`${server}/memory`, secret, {
onmessage(event) {
try {
const data = JSON.parse(event.data);
next(null, data);
} catch (err) {
console.error("[Memory] 解析数据错误:", err);
}
}
});
return () => socket.close();
}
);
// 提供统一的刷新方法
const refreshAll = async () => {
await Promise.all([
refreshProxy(),
refreshClashConfig(),
refreshRules(),
refreshSysproxy(),
refreshProxyProviders(),
refreshRuleProviders()
]);
};
// 聚合所有数据
const value = useMemo(() => ({
// 数据
proxies: proxiesData,
clashConfig,
rules: rulesData || [],
sysproxy,
runningMode,
uptime: uptimeData || 0,
// 提供者数据
proxyProviders: proxyProviders || {},
ruleProviders: ruleProviders || {},
// 连接数据
connections: {
data: connectionsData.connections || [],
count: connectionsData.connections?.length || 0,
uploadTotal: connectionsData.uploadTotal || 0,
downloadTotal: connectionsData.downloadTotal || 0
},
// 实时流量数据
traffic: trafficData,
memory: memoryData,
// 刷新方法
refreshProxy,
refreshClashConfig,
refreshRules,
refreshSysproxy,
refreshProxyProviders,
refreshRuleProviders,
refreshAll
}), [
proxiesData, clashConfig, rulesData, sysproxy,
runningMode, uptimeData, connectionsData,
trafficData, memoryData, proxyProviders, ruleProviders,
refreshProxy, refreshClashConfig, refreshRules, refreshSysproxy,
refreshProxyProviders, refreshRuleProviders
]);
return (
<AppDataContext.Provider value={value}>
{children}
</AppDataContext.Provider>
);
};
// 自定义Hook访问全局数据
export const useAppData = () => {
const context = useContext(AppDataContext);
if (!context) {
throw new Error("useAppData必须在AppDataProvider内使用");
}
return context;
};