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发行版可用将在未来做进一步适配和调优 - 仅在Ubuntu 22.04/24.04Fedora 41 **Gnome桌面环境** 做过简单测试不保证其他其他Linux发行版可用将在未来做进一步适配和调优
### 2.2.3-alpha 相对于 2.2.2 ### 2.2.3-alpha 相对于 2.2.2
#### 修复了:
- 首页“当前代理”因为重复刷新导致的CPU占用过高的问题
#### 优化 #### 优化
- 重构了内核管理逻辑,更轻量化和有效的管理内核,提高了性能和稳定性 - 重构了内核管理逻辑,更轻量化和有效的管理内核,提高了性能和稳定性
- 集中管理应用数据,优化数据获取和刷新逻辑
## v2.2.2 ## v2.2.2
@ -18,10 +22,10 @@
#### 已知问题 #### 已知问题
- 仅在Ubuntu 22.04/24.04Fedora 41 **Gnome桌面环境** 做过简单测试不保证其他其他Linux发行版可用将在未来做进一步适配和调优 - 仅在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翻译 - 增加轻量模式缺失的tray翻译
- Linux下的窗口边框被削掉的问题 - Linux下的窗口边框被削掉的问题
@ -31,7 +35,7 @@
- 增加服务模式下的僵尸进程清理机制 - 增加服务模式下的僵尸进程清理机制
- 新增当服务模式多次尝试失败后自动回退至用户空间模式 - 新增当服务模式多次尝试失败后自动回退至用户空间模式
### 2.2.1 相对于 2.2.0(已下架不提供) ### 2.2.1 相对于 2.2.0(已下架不提供)
#### 修复了: #### 修复了:
1. **首页** 1. **首页**
- 修复 Direct 模式首页无法渲染 - 修复 Direct 模式首页无法渲染
@ -62,7 +66,7 @@
--- ---
## 2.2.0(已下架不提供) ## 2.2.0(已下架不提供)
#### 新增功能 #### 新增功能
1. **首页** 1. **首页**
@ -141,14 +145,14 @@
感谢 Tychristine 对社区群组管理做出的重大贡献! 感谢 Tychristine 对社区群组管理做出的重大贡献!
##### 2.1.2相对2.1.1(已下架不提供)更新了: ##### 2.1.2相对2.1.1(已下架不提供)更新了:
- 无法更新和签名验证失败的问题(该死的CDN缓存) - 无法更新和签名验证失败的问题(该死的CDN缓存)
- 设置菜单区分Verge基本设置和高级设置 - 设置菜单区分Verge基本设置和高级设置
- 增加v2 Updater的更多功能和权限 - 增加v2 Updater的更多功能和权限
- 退出Verge后Tun代理状态仍保留的问题 - 退出Verge后Tun代理状态仍保留的问题
##### 2.1.1相对2.1.0(已下架不提供)更新了: ##### 2.1.1相对2.1.0(已下架不提供)更新了:
- 检测所需的Clash Verge Service版本杀毒软件误报可能与此有关因为检测和安装新版本Service需管理员权限 - 检测所需的Clash Verge Service版本杀毒软件误报可能与此有关因为检测和安装新版本Service需管理员权限
- MacOS下支持彩色托盘图标和更好速率显示感谢Tunglies - 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 { useTranslation } from "react-i18next";
import { Typography, Stack, Divider } from "@mui/material"; import { Typography, Stack, Divider } from "@mui/material";
import { DeveloperBoardOutlined } from "@mui/icons-material"; import { DeveloperBoardOutlined } from "@mui/icons-material";
import { useClashInfo } from "@/hooks/use-clash";
import { useClash } from "@/hooks/use-clash"; import { useClash } from "@/hooks/use-clash";
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from "./enhanced-card";
import useSWR from "swr"; import { useMemo } from "react";
import { getRules } from "@/services/api"; import { useAppData } from "@/providers/app-data-provider";
import { getAppUptime, getSystemProxy } from "@/services/cmds";
import { useMemo, useState, useEffect } from "react";
// 将毫秒转换为时:分:秒格式的函数 // 将毫秒转换为时:分:秒格式的函数
const formatUptime = (uptimeMs: number) => { const formatUptime = (uptimeMs: number) => {
@ -19,37 +16,15 @@ const formatUptime = (uptimeMs: number) => {
export const ClashInfoCard = () => { export const ClashInfoCard = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { version: clashVersion } = useClash(); const { version: clashVersion } = useClash();
const [sysproxy, setSysproxy] = useState<{ server: string; enable: boolean; bypass: string } | null>(null); const { clashConfig, sysproxy, rules, uptime } = useAppData();
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([]));
}, []);
// 使用useMemo缓存格式化后的uptime避免频繁计算 // 使用useMemo缓存格式化后的uptime避免频繁计算
const uptime = useMemo(() => formatUptime(uptimeMs), [uptimeMs]); const formattedUptime = useMemo(() => formatUptime(uptime), [uptime]);
// 使用备忘录组件内容,减少重新渲染 // 使用备忘录组件内容,减少重新渲染
const cardContent = useMemo(() => { const cardContent = useMemo(() => {
if (!clashInfo) return null; if (!clashConfig) return null;
return ( return (
<Stack spacing={1.5}> <Stack spacing={1.5}>
@ -76,7 +51,7 @@ export const ClashInfoCard = () => {
{t("Mixed Port")} {t("Mixed Port")}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{clashInfo.mixed_port || "-"} {clashConfig["mixed-port"] || "-"}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />
@ -85,7 +60,7 @@ export const ClashInfoCard = () => {
{t("Uptime")} {t("Uptime")}
</Typography> </Typography>
<Typography variant="body2" fontWeight="medium"> <Typography variant="body2" fontWeight="medium">
{uptime} {formattedUptime}
</Typography> </Typography>
</Stack> </Stack>
<Divider /> <Divider />
@ -99,7 +74,7 @@ export const ClashInfoCard = () => {
</Stack> </Stack>
</Stack> </Stack>
); );
}, [clashInfo, clashVersion, t, uptime, rules.length, sysproxy]); }, [clashConfig, clashVersion, t, formattedUptime, rules.length, sysproxy]);
return ( return (
<EnhancedCard <EnhancedCard

View File

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

View File

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

View File

@ -28,6 +28,7 @@ import { createAuthSockette } from "@/utils/websocket";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { getConnections, isDebugEnabled, gc } from "@/services/api"; import { getConnections, isDebugEnabled, gc } from "@/services/api";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { useAppData } from "@/providers/app-data-provider";
interface MemoryUsage { interface MemoryUsage {
inuse: number; inuse: number;
@ -157,11 +158,13 @@ export const EnhancedTrafficStats = () => {
const pageVisible = useVisibility(); const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false); const [isDebug, setIsDebug] = useState(false);
// 使用AppDataProvider
const { connections, uptime } = useAppData();
// 使用单一状态对象减少状态更新次数 // 使用单一状态对象减少状态更新次数
const [stats, setStats] = useState({ const [stats, setStats] = useState({
traffic: { up: 0, down: 0 }, traffic: { up: 0, down: 0 },
memory: { inuse: 0, oslimit: undefined as number | undefined }, 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, 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(() => { useEffect(() => {
isDebugEnabled().then((flag) => setIsDebug(flag)); isDebugEnabled().then((flag) => setIsDebug(flag));
@ -328,14 +301,14 @@ export const EnhancedTrafficStats = () => {
const [up, upUnit] = parseTraffic(stats.traffic.up); const [up, upUnit] = parseTraffic(stats.traffic.up);
const [down, downUnit] = parseTraffic(stats.traffic.down); const [down, downUnit] = parseTraffic(stats.traffic.down);
const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse); const [inuse, inuseUnit] = parseTraffic(stats.memory.inuse);
const [uploadTotal, uploadTotalUnit] = parseTraffic(stats.connections.uploadTotal); const [uploadTotal, uploadTotalUnit] = parseTraffic(connections.uploadTotal);
const [downloadTotal, downloadTotalUnit] = parseTraffic(stats.connections.downloadTotal); const [downloadTotal, downloadTotalUnit] = parseTraffic(connections.downloadTotal);
return { return {
up, upUnit, down, downUnit, inuse, inuseUnit, up, upUnit, down, downUnit, inuse, inuseUnit,
uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit uploadTotal, uploadTotalUnit, downloadTotal, downloadTotalUnit
}; };
}, [stats]); }, [stats, connections.uploadTotal, connections.downloadTotal]);
// 渲染流量图表 - 使用useMemo缓存渲染结果 // 渲染流量图表 - 使用useMemo缓存渲染结果
const trafficGraphComponent = useMemo(() => { const trafficGraphComponent = useMemo(() => {
@ -398,7 +371,7 @@ export const EnhancedTrafficStats = () => {
{ {
icon: <LinkRounded fontSize="small" />, icon: <LinkRounded fontSize="small" />,
title: t("Active Connections"), title: t("Active Connections"),
value: stats.connections.activeConnections, value: connections.count,
unit: "", unit: "",
color: "success" as const, color: "success" as const,
}, },
@ -424,7 +397,7 @@ export const EnhancedTrafficStats = () => {
color: "error" as const, color: "error" as const,
onClick: isDebug ? handleGarbageCollection : undefined, onClick: isDebug ? handleGarbageCollection : undefined,
}, },
], [t, parsedData, stats.connections.activeConnections, isDebug, handleGarbageCollection]); ], [t, parsedData, connections.count, isDebug, handleGarbageCollection]);
return ( return (
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}> <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 { useLockFn } from "ahooks";
import { Notice } from "@/components/base"; import { Notice } from "@/components/base";
import { EnhancedCard } from "./enhanced-card"; import { EnhancedCard } from "./enhanced-card";
import { useAppData } from "@/providers/app-data-provider";
// 定义旋转动画 // 定义旋转动画
const round = keyframes` const round = keyframes`
@ -270,6 +271,7 @@ const EmptyProfile = ({ onClick }: { onClick: () => void }) => {
export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardProps) => { export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { refreshAll } = useAppData();
// 更新当前订阅 // 更新当前订阅
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
@ -282,6 +284,9 @@ export const HomeProfileCard = ({ current, onProfileUpdated }: HomeProfileCardPr
await updateProfile(current.uid); await updateProfile(current.uid);
Notice.success(t("Update subscription successfully")); Notice.success(t("Update subscription successfully"));
onProfileUpdated?.(); onProfileUpdated?.();
// 刷新首页数据
refreshAll();
} catch (err: any) { } catch (err: any) {
Notice.error(err?.message || err.toString()); Notice.error(err?.message || err.toString());
} finally { } finally {

View File

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

View File

@ -1,195 +1,46 @@
import dayjs from "dayjs";
import useSWR, { mutate } from "swr";
import { useState } from "react"; import { useState } from "react";
import { import {
Button, Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton, IconButton,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
styled,
Box,
alpha,
Typography, Typography,
Divider, Divider,
LinearProgress, LinearProgress,
keyframes, alpha,
styled,
useTheme
} from "@mui/material"; } from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getProxyProviders, proxyProviderUpdate } from "@/services/api"; import { useLockFn } from "ahooks";
import { BaseDialog } from "../base"; 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"; import parseTraffic from "@/utils/parse-traffic";
const round = keyframes` // 定义代理提供者类型
from { transform: rotate(0deg); } interface ProxyProviderItem {
to { transform: rotate(360deg); } name?: string;
`; proxies: any[];
updatedAt: number;
export const ProviderButton = () => { vehicleType: string;
const { t } = useTranslation(); subscriptionInfo?: {
const { data } = useSWR("getProxyProviders", getProxyProviders); Upload: number;
Download: number;
const [open, setOpen] = useState(false); Total: number;
Expire: number;
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");
});
}; };
}
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 }) => ({ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block", display: "inline-block",
border: "1px solid #ccc", border: "1px solid #ccc",
@ -202,28 +53,272 @@ const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
lineHeight: 1.25, lineHeight: 1.25,
})); }));
const StyledTypeBox = styled(Box)<{ component?: React.ElementType }>( // 解析过期时间
({ theme }) => ({ const parseExpire = (expire?: number) => {
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) {
if (!expire) return "-"; if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD"); 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 { interface RenderProps {
item: IRenderItem; item: IRenderItem;
indent: boolean; indent: boolean;
onLocation: (group: IProxyGroupItem) => void; onLocation: (group: IRenderItem["group"]) => void;
onCheckAll: (groupName: string) => void; onCheckAll: (groupName: string) => void;
onHeadState: (groupName: string, patch: Partial<HeadState>) => 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) => { export const ProxyRender = (props: RenderProps) => {

View File

@ -1,6 +1,4 @@
import useSWR from "swr";
import { useEffect, useMemo, useCallback } from "react"; import { useEffect, useMemo, useCallback } from "react";
import { getProxies } from "@/services/api";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { filterSort } from "./use-filter-sort"; import { filterSort } from "./use-filter-sort";
import { useWindowWidth } from "./use-window-width"; import { useWindowWidth } from "./use-window-width";
@ -9,12 +7,52 @@ import {
DEFAULT_STATE, DEFAULT_STATE,
type HeadState, type HeadState,
} from "./use-head-state"; } 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 { export interface IRenderItem {
// 组 | head | item | empty | item col // 组 | head | item | empty | item col
type: 0 | 1 | 2 | 3 | 4; type: 0 | 1 | 2 | 3 | 4;
key: string; key: string;
group: IProxyGroupItem; group: ProxyGroup;
proxy?: IProxyItem; proxy?: IProxyItem;
col?: number; col?: number;
proxyCol?: IProxyItem[]; proxyCol?: IProxyItem[];
@ -51,16 +89,8 @@ const groupProxies = <T = any>(list: T[], size: number): T[][] => {
}; };
export const useRenderList = (mode: string) => { export const useRenderList = (mode: string) => {
const { data: proxiesData, mutate: mutateProxies } = useSWR( // 使用全局数据提供者
"getProxies", const { proxies: proxiesData, refreshProxy } = useAppData();
getProxies,
{
refreshInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
);
const { verge } = useVerge(); const { verge } = useVerge();
const { width } = useWindowWidth(); const { width } = useWindowWidth();
const [headStates, setHeadState] = useHeadStateNew(); const [headStates, setHeadState] = useHeadStateNew();
@ -80,9 +110,9 @@ export const useRenderList = (mode: string) => {
(mode === "rule" && !groups.length) || (mode === "rule" && !groups.length) ||
(mode === "global" && proxies.length < 2) (mode === "global" && proxies.length < 2)
) { ) {
setTimeout(() => mutateProxies(), 500); setTimeout(() => refreshProxy(), 500);
} }
}, [proxiesData, mode, mutateProxies]); }, [proxiesData, mode, refreshProxy]);
// 处理渲染列表 // 处理渲染列表
const renderList: IRenderItem[] = useMemo(() => { const renderList: IRenderItem[] = useMemo(() => {
@ -94,7 +124,7 @@ export const useRenderList = (mode: string) => {
? proxiesData.groups ? proxiesData.groups
: [proxiesData.global!]; : [proxiesData.global!];
const retList = renderGroups.flatMap((group) => { const retList = renderGroups.flatMap((group: ProxyGroup) => {
const headState = headStates[group.name] || DEFAULT_STATE; const headState = headStates[group.name] || DEFAULT_STATE;
const ret: IRenderItem[] = [ const ret: IRenderItem[] = [
{ {
@ -158,12 +188,12 @@ export const useRenderList = (mode: string) => {
}); });
if (!useRule) return retList.slice(1); 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]); }, [headStates, proxiesData, mode, col]);
return { return {
renderList, renderList,
onProxies: mutateProxies, onProxies: refreshProxy,
onHeadState: setHeadState, onHeadState: setHeadState,
currentColumns: col, currentColumns: col,
}; };

View File

@ -1,170 +1,39 @@
import dayjs from "dayjs";
import useSWR, { mutate } from "swr";
import { useState } from "react"; import { useState } from "react";
import { import {
Button, Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton, IconButton,
List, List,
ListItem, ListItem,
ListItemText, ListItemText,
Typography, Typography,
styled,
Box,
alpha,
Divider, Divider,
keyframes, alpha,
styled,
useTheme
} from "@mui/material"; } from "@mui/material";
import { RefreshRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { getRuleProviders, ruleProviderUpdate } from "@/services/api"; import { useLockFn } from "ahooks";
import { BaseDialog } from "../base"; 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); } interface RuleProviderItem {
to { transform: rotate(360deg); } behavior: string;
`; ruleCount: number;
updatedAt: number;
vehicleType: string;
}
export const ProviderButton = () => { // 辅助组件 - 类型框
const { t } = useTranslation(); const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
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 }) => ({
display: "inline-block", display: "inline-block",
border: "1px solid #ccc", border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5), borderColor: alpha(theme.palette.secondary.main, 0.5),
@ -176,16 +45,222 @@ const TypeBox = styled(Box, {
lineHeight: 1.25, lineHeight: 1.25,
})); }));
const StyledTypeBox = styled(Box, { export const ProviderButton = () => {
shouldForwardProp: (prop) => prop !== "component", const { t } = useTranslation();
})<{ component?: React.ElementType }>(({ theme }) => ({ const theme = useTheme();
display: "inline-block", const [open, setOpen] = useState(false);
border: "1px solid #ccc", const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData();
borderColor: alpha(theme.palette.primary.main, 0.5), const [updating, setUpdating] = useState<Record<string, boolean>>({});
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4, // 检查是否有提供者
fontSize: 10, const hasProviders = Object.keys(ruleProviders || {}).length > 0;
marginRight: "4px",
padding: "0 2px", // 更新单个规则提供者
lineHeight: 1.25, 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 { useMemo } from "react";
import { getProxies } from "@/services/api"; import { useAppData } from "@/providers/app-data-provider";
import { getClashConfig } from "@/services/api";
// 定义代理组类型
interface ProxyGroup {
name: string;
now: string;
}
// 获取当前代理节点信息的自定义Hook // 获取当前代理节点信息的自定义Hook
export const useCurrentProxy = () => { export const useCurrentProxy = () => {
// 获取代理信息 // 从AppDataProvider获取数据
const { data: proxiesData, mutate: mutateProxies } = useSWR( const { proxies, clashConfig, refreshProxy } = useAppData();
"getProxies",
getProxies,
{
refreshInterval: 2000,
revalidateOnFocus: false,
revalidateOnReconnect: true,
},
);
// 获取当前Clash配置包含模式信息
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
// 获取当前模式 // 获取当前模式
const currentMode = clashConfig?.mode?.toLowerCase() || "rule"; const currentMode = clashConfig?.mode?.toLowerCase() || "rule";
// 获取当前代理节点信息 // 获取当前代理节点信息
const currentProxyInfo = useMemo(() => { 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"; let primaryGroupName = "GLOBAL";
@ -43,11 +36,11 @@ export const useCurrentProxy = () => {
"自动选择", "自动选择",
]; ];
const primaryGroup = const primaryGroup =
groups.find((group) => groups.find((group: ProxyGroup) =>
primaryKeywords.some((keyword) => primaryKeywords.some((keyword) =>
group.name.toLowerCase().includes(keyword.toLowerCase()), group.name.toLowerCase().includes(keyword.toLowerCase()),
), ),
) || groups.filter((g) => g.name !== "GLOBAL")[0]; ) || groups.filter((g: ProxyGroup) => g.name !== "GLOBAL")[0];
if (primaryGroup) { if (primaryGroup) {
primaryGroupName = primaryGroup.name; primaryGroupName = primaryGroup.name;
@ -71,12 +64,12 @@ export const useCurrentProxy = () => {
}; };
return { currentProxy, primaryGroupName }; return { currentProxy, primaryGroupName };
}, [proxiesData, currentMode]); }, [proxies, currentMode]);
return { return {
currentProxy: currentProxyInfo.currentProxy, currentProxy: currentProxyInfo.currentProxy,
primaryGroupName: currentProxyInfo.primaryGroupName, primaryGroupName: currentProxyInfo.primaryGroupName,
mode: currentMode, mode: currentMode,
refreshProxy: mutateProxies, refreshProxy,
}; };
}; };

View File

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

View File

@ -11,7 +11,6 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { closeAllConnections } from "@/services/api"; import { closeAllConnections } from "@/services/api";
import { useConnectionSetting } from "@/services/states"; import { useConnectionSetting } from "@/services/states";
import { useClashInfo } from "@/hooks/use-clash";
import { BaseEmpty, BasePage } from "@/components/base"; import { BaseEmpty, BasePage } from "@/components/base";
import { ConnectionItem } from "@/components/connection/connection-item"; import { ConnectionItem } from "@/components/connection/connection-item";
import { ConnectionTable } from "@/components/connection/connection-table"; import { ConnectionTable } from "@/components/connection/connection-table";
@ -25,10 +24,9 @@ import {
type SearchState, type SearchState,
} from "@/components/base/base-search-box"; } from "@/components/base/base-search-box";
import { BaseStyledSelect } from "@/components/base/base-styled-select"; 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 { useTheme } from "@mui/material/styles";
import { useVisibility } from "@/hooks/use-visibility"; import { useVisibility } from "@/hooks/use-visibility";
import { useAppData } from "@/providers/app-data-provider";
const initConn: IConnections = { const initConn: IConnections = {
uploadTotal: 0, uploadTotal: 0,
@ -40,13 +38,15 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
const ConnectionsPage = () => { const ConnectionsPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const pageVisible = useVisibility(); const pageVisible = useVisibility();
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
const [match, setMatch] = useState(() => (_: string) => true); const [match, setMatch] = useState(() => (_: string) => true);
const [curOrderOpt, setOrderOpt] = useState("Default"); const [curOrderOpt, setOrderOpt] = useState("Default");
// 使用全局数据
const { connections } = useAppData();
const [setting, setSetting] = useConnectionSetting(); const [setting, setSetting] = useConnectionSetting();
const isTableLayout = setting.layout === "table"; const isTableLayout = setting.layout === "table";
@ -66,99 +66,37 @@ const ConnectionsPage = () => {
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [frozenData, setFrozenData] = useState<IConnections | null>(null); 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(() => { const displayData = useMemo(() => {
return isPaused ? (frozenData ?? connData) : connData; if (!pageVisible) return initConn;
}, [isPaused, frozenData, connData]);
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 [filterConn] = useMemo(() => {
const orderFunc = orderOpts[curOrderOpt]; const orderFunc = orderOpts[curOrderOpt];
let connections = displayData.connections.filter((conn) => { let conns = displayData.connections.filter((conn) => {
const { host, destinationIP, process } = conn.metadata; const { host, destinationIP, process } = conn.metadata;
return ( return (
match(host || "") || match(destinationIP || "") || match(process || "") match(host || "") || match(destinationIP || "") || match(process || "")
); );
}); });
if (orderFunc) connections = orderFunc(connections); if (orderFunc) conns = orderFunc(conns);
return [connections]; return [conns];
}, [displayData, match, curOrderOpt]); }, [displayData, match, curOrderOpt]);
const onCloseAll = useLockFn(closeAllConnections); const onCloseAll = useLockFn(closeAllConnections);
@ -172,13 +110,17 @@ const ConnectionsPage = () => {
const handlePauseToggle = useCallback(() => { const handlePauseToggle = useCallback(() => {
setIsPaused((prev) => { setIsPaused((prev) => {
if (!prev) { if (!prev) {
setFrozenData(connData); setFrozenData({
uploadTotal: connections.uploadTotal,
downloadTotal: connections.downloadTotal,
connections: connections.data
});
} else { } else {
setFrozenData(null); setFrozenData(null);
} }
return !prev; return !prev;
}); });
}, [connData]); }, [connections]);
return ( return (
<BasePage <BasePage

View File

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

View File

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