feat: home page

This commit is contained in:
wonfen
2025-03-14 13:31:34 +08:00
parent 49f0ac407e
commit 9fd75f0efb
37 changed files with 4488 additions and 74 deletions

View File

@@ -0,0 +1,109 @@
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 } from "@/services/cmds";
import { useState } from "react";
export const ClashInfoCard = () => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
const { version: clashVersion } = useClash();
// 计算运行时间
const [uptime, setUptime] = useState("0:00:00");
// 使用SWR定期获取应用运行时间
useSWR(
"appUptime",
async () => {
const uptimeMs = await getAppUptime();
// 将毫秒转换为时:分:秒格式
const hours = Math.floor(uptimeMs / 3600000);
const minutes = Math.floor((uptimeMs % 3600000) / 60000);
const seconds = Math.floor((uptimeMs % 60000) / 1000);
setUptime(
`${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`,
);
return uptimeMs;
},
{
refreshInterval: 1000, // 每秒更新一次
revalidateOnFocus: false,
dedupingInterval: 500,
},
);
// 获取规则数
const { data: rulesData } = useSWR("getRules", getRules, {
fallbackData: [],
suspense: false,
revalidateOnFocus: false,
errorRetryCount: 2,
});
// 获取规则数据
const rules = rulesData || [];
return (
<EnhancedCard
title={t("Clash Info")}
icon={<DeveloperBoardOutlined />}
iconColor="warning"
action={null}
>
{clashInfo && (
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Core Version")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashVersion || "-"}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("System Proxy Address")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashInfo.server || "-"}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Mixed Port")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{clashInfo.mixed_port || "-"}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Uptime")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{uptime}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Rules Count")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{rules.length}
</Typography>
</Stack>
</Stack>
)}
</EnhancedCard>
);
};

View File

@@ -0,0 +1,250 @@
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 { patchClashMode } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import {
LanguageRounded,
MultipleStopRounded,
DirectionsRounded,
} from "@mui/icons-material";
import { useState, useEffect } from "react";
export const ClashModeCard = () => {
const { t } = useTranslation();
const { verge } = useVerge();
// 获取当前Clash配置
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig,
{
revalidateOnFocus: false,
},
);
// 支持的模式列表 - 添加直连模式
const modeList = ["rule", "global", "direct"];
// 本地状态记录当前模式提供更快的UI响应
const [localMode, setLocalMode] = useState<string>("rule");
// 当从API获取到当前模式时更新本地状态
useEffect(() => {
if (clashConfig?.mode) {
setLocalMode(clashConfig.mode.toLowerCase());
}
}, [clashConfig]);
// 切换模式的处理函数
const onChangeMode = useLockFn(async (mode: string) => {
// 如果已经是当前模式,不做任何操作
if (mode === localMode) return;
// 立即更新本地UI状态
setLocalMode(mode);
// 断开连接(如果启用了设置)
if (verge?.auto_close_connection) {
closeAllConnections();
}
try {
await patchClashMode(mode);
// 成功后刷新数据
mutateClash();
} catch (error) {
// 如果操作失败,恢复之前的状态
console.error("Failed to change mode:", error);
if (clashConfig?.mode) {
setLocalMode(clashConfig.mode.toLowerCase());
}
}
});
// 获取模式对应的图标
const getModeIcon = (mode: string) => {
switch (mode) {
case "rule":
return <MultipleStopRounded fontSize="small" />;
case "global":
return <LanguageRounded fontSize="small" />;
case "direct":
return <DirectionsRounded fontSize="small" />;
default:
return null;
}
};
// 获取模式说明文字
const getModeDescription = (mode: string) => {
switch (mode) {
case "rule":
return t("Rule Mode Description");
case "global":
return t("Global Mode Description");
case "direct":
return t("Direct Mode Description");
default:
return "";
}
};
return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{/* 模式选择按钮组 */}
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
justifyContent: "center",
py: 1,
position: "relative",
zIndex: 2,
}}
>
{modeList.map((mode) => (
<Paper
key={mode}
elevation={mode === localMode ? 2 : 0}
onClick={() => onChangeMode(mode)}
sx={{
cursor: "pointer",
px: 2,
py: 1.2,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 1,
bgcolor: mode === localMode ? "primary.main" : "background.paper",
color:
mode === localMode ? "primary.contrastText" : "text.primary",
borderRadius: 1.5,
transition: "all 0.2s ease-in-out",
position: "relative",
overflow: "visible",
"&:hover": {
transform: "translateY(-1px)",
boxShadow: 1,
},
"&:active": {
transform: "translateY(1px)",
},
"&::after":
mode === localMode
? {
content: '""',
position: "absolute",
bottom: -16,
left: "50%",
width: 2,
height: 16,
bgcolor: "primary.main",
transform: "translateX(-50%)",
}
: {},
}}
>
{getModeIcon(mode)}
<Typography
variant="body2"
sx={{
textTransform: "capitalize",
fontWeight: mode === localMode ? 600 : 400,
}}
>
{t(mode)}
</Typography>
</Paper>
))}
</Stack>
{/* 说明文本区域 */}
<Box
sx={{
width: "100%",
my: 1,
position: "relative",
display: "flex",
justifyContent: "center",
overflow: "visible",
}}
>
{localMode === "rule" && (
<Fade in={true} timeout={200}>
<Typography
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
wordBreak: "break-word",
hyphens: "auto",
}}
>
{getModeDescription("rule")}
</Typography>
</Fade>
)}
{localMode === "global" && (
<Fade in={true} timeout={200}>
<Typography
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
wordBreak: "break-word",
hyphens: "auto",
}}
>
{getModeDescription("global")}
</Typography>
</Fade>
)}
{localMode === "direct" && (
<Fade in={true} timeout={200}>
<Typography
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
wordBreak: "break-word",
hyphens: "auto",
}}
>
{getModeDescription("direct")}
</Typography>
</Fade>
)}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,653 @@
import { useTranslation } from "react-i18next";
import {
Box,
Typography,
Stack,
Chip,
Button,
alpha,
useTheme,
Select,
MenuItem,
FormControl,
InputLabel,
SelectChangeEvent,
Tooltip,
} from "@mui/material";
import { useEffect, useState } from "react";
import {
RouterOutlined,
SignalWifi4Bar as SignalStrong,
SignalWifi3Bar as SignalGood,
SignalWifi2Bar as SignalMedium,
SignalWifi1Bar as SignalWeak,
SignalWifi0Bar as SignalNone,
WifiOff as SignalError,
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 } from "@/services/api";
import delayManager from "@/services/delay";
// 本地存储的键名
const STORAGE_KEY_GROUP = "clash-verge-selected-proxy-group";
const STORAGE_KEY_PROXY = "clash-verge-selected-proxy";
// 代理节点信息接口
interface ProxyOption {
name: string;
}
// 将delayManager返回的颜色格式转换为MUI Chip组件需要的格式
function convertDelayColor(
delayValue: number,
):
| "default"
| "success"
| "warning"
| "error"
| "primary"
| "secondary"
| "info"
| undefined {
const colorStr = delayManager.formatDelayColor(delayValue);
if (!colorStr) return "default";
// 从"error.main"这样的格式转为"error"
const mainColor = colorStr.split(".")[0];
switch (mainColor) {
case "success":
return "success";
case "warning":
return "warning";
case "error":
return "error";
case "primary":
return "primary";
default:
return "default";
}
}
// 根据延迟值获取合适的WiFi信号图标
function getSignalIcon(delay: number): {
icon: JSX.Element;
text: string;
color: string;
} {
if (delay < 0)
return {
icon: <SignalNone />,
text: "未测试",
color: "text.secondary",
};
if (delay >= 10000)
return {
icon: <SignalError />,
text: "超时",
color: "error.main",
};
if (delay >= 500)
return {
icon: <SignalWeak />,
text: "延迟较高",
color: "error.main",
};
if (delay >= 300)
return {
icon: <SignalMedium />,
text: "延迟中等",
color: "warning.main",
};
if (delay >= 200)
return {
icon: <SignalGood />,
text: "延迟良好",
color: "info.main",
};
return {
icon: <SignalStrong />,
text: "延迟极佳",
color: "success.main",
};
}
export const CurrentProxyCard = () => {
const { t } = useTranslation();
const { currentProxy, primaryGroupName, mode, refreshProxy } =
useCurrentProxy();
const navigate = useNavigate();
const theme = useTheme();
// 判断模式
const isGlobalMode = mode === "global";
const isDirectMode = mode === "direct"; // 添加直连模式判断
// 从本地存储获取初始值,如果是特殊模式或没有存储值则使用默认值
const getSavedGroup = () => {
// 全局模式使用 GLOBAL 组
if (isGlobalMode) {
return "GLOBAL";
}
// 直连模式使用 DIRECT
if (isDirectMode) {
return "DIRECT";
}
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
return savedGroup || primaryGroupName || "GLOBAL";
};
// 状态管理
const [groups, setGroups] = useState<
{ name: string; now: string; all: string[] }[]
>([]);
const [selectedGroup, setSelectedGroup] = useState<string>(getSavedGroup());
const [proxyOptions, setProxyOptions] = useState<ProxyOption[]>([]);
const [selectedProxy, setSelectedProxy] = useState<string>("");
const [displayProxy, setDisplayProxy] = useState<any>(null);
const [records, setRecords] = useState<Record<string, any>>({});
const [globalProxy, setGlobalProxy] = useState<string>(""); // 存储全局代理
const [directProxy, setDirectProxy] = useState<any>(null); // 存储直连代理信息
// 保存选择的代理组到本地存储
useEffect(() => {
// 只有在普通模式下才保存到本地存储
if (selectedGroup && !isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_GROUP, selectedGroup);
}
}, [selectedGroup, isGlobalMode, isDirectMode]);
// 保存选择的代理节点到本地存储
useEffect(() => {
// 只有在普通模式下才保存到本地存储
if (selectedProxy && !isGlobalMode && !isDirectMode) {
localStorage.setItem(STORAGE_KEY_PROXY, selectedProxy);
}
}, [selectedProxy, isGlobalMode, isDirectMode]);
// 当模式变化时更新选择的组
useEffect(() => {
if (isGlobalMode) {
setSelectedGroup("GLOBAL");
} else if (isDirectMode) {
setSelectedGroup("DIRECT");
} else if (primaryGroupName) {
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
setSelectedGroup(savedGroup || primaryGroupName);
}
}, [isGlobalMode, isDirectMode, primaryGroupName]);
// 获取所有代理组和代理信息
useEffect(() => {
const fetchProxies = async () => {
try {
const data = await getProxies();
// 保存所有节点记录信息,用于显示详细节点信息
setRecords(data.records);
// 检查并存储全局代理信息
if (data.global) {
setGlobalProxy(data.global.now || "");
}
// 查找并存储直连代理信息
if (data.records && data.records["DIRECT"]) {
setDirectProxy(data.records["DIRECT"]);
}
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),
}));
setGroups(filteredGroups);
// 直连模式处理
if (isDirectMode) {
// 直连模式下使用 DIRECT 节点
setSelectedGroup("DIRECT");
setSelectedProxy("DIRECT");
if (data.records && data.records["DIRECT"]) {
setDisplayProxy(data.records["DIRECT"]);
}
// 设置仅包含 DIRECT 节点的选项
setProxyOptions([{ name: "DIRECT" }]);
return;
}
// 全局模式处理
if (isGlobalMode) {
// 在全局模式下,使用 GLOBAL 组和 data.global.now 作为选中节点
if (data.global) {
const globalNow = data.global.now || "";
setSelectedGroup("GLOBAL");
setSelectedProxy(globalNow);
if (globalNow && data.records[globalNow]) {
setDisplayProxy(data.records[globalNow]);
}
// 设置全局组的代理选项
const options = data.global.all.map((proxy) => ({
name: proxy.name,
}));
setProxyOptions(options);
}
return;
}
// 以下是普通模式的处理逻辑
let targetGroup = primaryGroupName;
// 非特殊模式下,尝试从本地存储获取上次选择的代理组
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
targetGroup = savedGroup || primaryGroupName;
// 如果目标组在列表中,则选择它
if (targetGroup && filteredGroups.some((g) => g.name === targetGroup)) {
setSelectedGroup(targetGroup);
// 设置该组下的代理选项
const currentGroup = filteredGroups.find(
(g) => g.name === targetGroup,
);
if (currentGroup) {
// 创建代理选项
const options = currentGroup.all.map((proxyName) => {
return { name: proxyName };
});
setProxyOptions(options);
let targetProxy = currentGroup.now;
const savedProxy = localStorage.getItem(STORAGE_KEY_PROXY);
// 如果有保存的代理节点且该节点在当前组中,则选择它
if (savedProxy && currentGroup.all.includes(savedProxy)) {
targetProxy = savedProxy;
}
setSelectedProxy(targetProxy);
if (targetProxy && data.records[targetProxy]) {
setDisplayProxy(data.records[targetProxy]);
}
}
} else if (filteredGroups.length > 0) {
// 否则选择第一个组
setSelectedGroup(filteredGroups[0].name);
// 创建代理选项
const options = filteredGroups[0].all.map((proxyName) => {
return { name: proxyName };
});
setProxyOptions(options);
setSelectedProxy(filteredGroups[0].now);
// 更新显示的代理节点信息
if (filteredGroups[0].now && data.records[filteredGroups[0].now]) {
setDisplayProxy(data.records[filteredGroups[0].now]);
}
}
} catch (error) {
console.error("获取代理信息失败", error);
}
};
fetchProxies();
}, [primaryGroupName, isGlobalMode, isDirectMode]);
// 当选择的组发生变化时更新代理选项
useEffect(() => {
// 如果是特殊模式,已在 fetchProxies 中处理
if (isGlobalMode || isDirectMode) return;
const group = groups.find((g) => g.name === selectedGroup);
if (group && records) {
// 创建代理选项
const options = group.all.map((proxyName) => {
return { name: proxyName };
});
setProxyOptions(options);
let targetProxy = group.now;
const savedProxy = localStorage.getItem(STORAGE_KEY_PROXY);
// 如果保存的代理节点在当前组中,则选择它
if (savedProxy && group.all.includes(savedProxy)) {
targetProxy = savedProxy;
}
setSelectedProxy(targetProxy);
if (targetProxy && records[targetProxy]) {
setDisplayProxy(records[targetProxy]);
}
}
}, [selectedGroup, groups, records, isGlobalMode, isDirectMode]);
// 刷新代理信息
const refreshProxyData = async () => {
try {
const data = await getProxies();
// 检查并更新全局代理信息
if (isGlobalMode && data.global) {
const globalNow = data.global.now || "";
setSelectedProxy(globalNow);
if (globalNow && data.records[globalNow]) {
setDisplayProxy(data.records[globalNow]);
}
// 更新全局组的代理选项
const options = data.global.all.map((proxy) => ({
name: proxy.name,
}));
setProxyOptions(options);
}
// 更新直连代理信息
if (isDirectMode && data.records["DIRECT"]) {
setDirectProxy(data.records["DIRECT"]);
setDisplayProxy(data.records["DIRECT"]);
}
} catch (error) {
console.error("刷新代理信息失败", error);
}
};
// 每隔一段时间刷新特殊模式下的代理信息
useEffect(() => {
if (!isGlobalMode && !isDirectMode) return;
const refreshInterval = setInterval(refreshProxyData, 3000);
return () => clearInterval(refreshInterval);
}, [isGlobalMode, isDirectMode]);
// 处理代理组变更
const handleGroupChange = (event: SelectChangeEvent) => {
// 特殊模式下不允许切换组
if (isGlobalMode || isDirectMode) return;
const newGroup = event.target.value;
setSelectedGroup(newGroup);
};
// 处理代理节点变更
const handleProxyChange = async (event: SelectChangeEvent) => {
// 直连模式下不允许切换节点
if (isDirectMode) return;
const newProxy = event.target.value;
setSelectedProxy(newProxy);
// 更新显示的代理节点信息
if (records[newProxy]) {
setDisplayProxy(records[newProxy]);
}
try {
// 更新代理设置
await updateProxy(selectedGroup, newProxy);
setTimeout(() => {
refreshProxy();
if (isGlobalMode || isDirectMode) {
refreshProxyData(); // 特殊模式下额外刷新数据
}
}, 300);
} catch (error) {
console.error("更新代理失败", error);
}
};
// 导航到代理页面
const goToProxies = () => {
// 修正路由路径根据_routers.tsx配置代理页面的路径是"/"
navigate("/");
};
// 获取要显示的代理节点
const proxyToDisplay = displayProxy || currentProxy;
// 获取当前节点的延迟
const currentDelay = proxyToDisplay
? delayManager.getDelayFix(proxyToDisplay, selectedGroup)
: -1;
// 获取信号图标
const signalInfo = getSignalIcon(currentDelay);
// 自定义渲染选择框中的值
const renderProxyValue = (selected: string) => {
if (!selected || !records[selected]) return selected;
const delayValue = delayManager.getDelayFix(
records[selected],
selectedGroup,
);
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
}}
>
<Typography noWrap>{selected}</Typography>
<Chip
size="small"
label={delayManager.formatDelay(delayValue)}
color={convertDelayColor(delayValue)}
/>
</Box>
);
};
return (
<EnhancedCard
title={t("Current Node")}
icon={
<Tooltip
title={
proxyToDisplay
? `${signalInfo.text}: ${delayManager.formatDelay(currentDelay)}`
: "无代理节点"
}
>
<Box sx={{ color: signalInfo.color }}>
{proxyToDisplay ? signalInfo.icon : <SignalNone color="disabled" />}
</Box>
</Tooltip>
}
iconColor={proxyToDisplay ? "primary" : undefined}
action={
<Button
variant="outlined"
size="small"
onClick={goToProxies}
sx={{ borderRadius: 1.5 }}
endIcon={<ChevronRight fontSize="small" />}
>
{t("Label-Proxies")}
</Button>
}
>
{proxyToDisplay ? (
<Box>
{/* 代理节点信息显示 */}
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 1,
mb: 2,
borderRadius: 1,
bgcolor: alpha(theme.palette.primary.main, 0.05),
border: `1px solid ${alpha(theme.palette.primary.main, 0.1)}`,
}}
>
<Box>
<Typography variant="body1" fontWeight="medium">
{proxyToDisplay.name}
</Typography>
<Box
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap" }}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ mr: 1 }}
>
{proxyToDisplay.type}
</Typography>
{isGlobalMode && (
<Chip
size="small"
label={t("Global Mode")}
color="primary"
sx={{ mr: 0.5 }}
/>
)}
{isDirectMode && (
<Chip
size="small"
label={t("Direct Mode")}
color="success"
sx={{ mr: 0.5 }}
/>
)}
{/* 节点特性 */}
{proxyToDisplay.udp && (
<Chip size="small" label="UDP" variant="outlined" />
)}
{proxyToDisplay.tfo && (
<Chip size="small" label="TFO" variant="outlined" />
)}
{proxyToDisplay.xudp && (
<Chip size="small" label="XUDP" variant="outlined" />
)}
{proxyToDisplay.mptcp && (
<Chip size="small" label="MPTCP" variant="outlined" />
)}
{proxyToDisplay.smux && (
<Chip size="small" label="SMUX" variant="outlined" />
)}
</Box>
</Box>
{/* 显示延迟 */}
{proxyToDisplay && !isDirectMode && (
<Chip
size="small"
label={delayManager.formatDelay(
delayManager.getDelayFix(proxyToDisplay, selectedGroup),
)}
color={convertDelayColor(
delayManager.getDelayFix(proxyToDisplay, selectedGroup),
)}
/>
)}
</Box>
{/* 代理组选择器 */}
<FormControl
fullWidth
variant="outlined"
size="small"
sx={{ mb: 1.5 }}
>
<InputLabel id="proxy-group-select-label">{t("Group")}</InputLabel>
<Select
labelId="proxy-group-select-label"
value={selectedGroup}
onChange={handleGroupChange}
label={t("Group")}
disabled={isGlobalMode || isDirectMode} // 特殊模式下禁用选择器
>
{groups.map((group) => (
<MenuItem key={group.name} value={group.name}>
{group.name}
</MenuItem>
))}
</Select>
</FormControl>
{/* 代理节点选择器 */}
<FormControl fullWidth variant="outlined" size="small" sx={{ mb: 0 }}>
<InputLabel id="proxy-select-label">{t("Proxy")}</InputLabel>
<Select
labelId="proxy-select-label"
value={selectedProxy}
onChange={handleProxyChange}
label={t("Proxy")}
disabled={isDirectMode} // 直连模式下禁用选择器
renderValue={renderProxyValue}
MenuProps={{
PaperProps: {
style: {
maxHeight: 500,
},
},
}}
>
{proxyOptions.map((proxy) => {
const delayValue = delayManager.getDelayFix(
records[proxy.name],
selectedGroup,
);
return (
<MenuItem
key={proxy.name}
value={proxy.name}
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
pr: 1,
}}
>
<Typography noWrap sx={{ flex: 1, mr: 1 }}>
{proxy.name}
</Typography>
<Chip
size="small"
label={delayManager.formatDelay(delayValue)}
color={convertDelayColor(delayValue)}
sx={{
minWidth: "60px",
height: "22px",
flexShrink: 0,
}}
/>
</MenuItem>
);
})}
</Select>
</FormControl>
</Box>
) : (
<Box sx={{ textAlign: "center", py: 4 }}>
<Typography variant="body1" color="text.secondary">
{t("No active proxy node")}
</Typography>
</Box>
)}
</EnhancedCard>
);
};

View File

@@ -0,0 +1,94 @@
import { Box, Typography, alpha, useTheme } from "@mui/material";
import { ReactNode } from "react";
// 自定义卡片组件接口
export interface EnhancedCardProps {
title: ReactNode;
icon: ReactNode;
action?: ReactNode;
children: ReactNode;
iconColor?:
| "primary"
| "secondary"
| "error"
| "warning"
| "info"
| "success";
minHeight?: number | string;
noContentPadding?: boolean;
}
// 自定义卡片组件
export const EnhancedCard = ({
title,
icon,
action,
children,
iconColor = "primary",
minHeight,
noContentPadding = false,
}: EnhancedCardProps) => {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
return (
<Box
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
borderRadius: 2,
backgroundColor: isDark ? "#282a36" : "#ffffff",
}}
>
<Box
sx={{
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderBottom: 1,
borderColor: "divider",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 1.5,
width: 38,
height: 38,
mr: 1.5,
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
color: theme.palette[iconColor].main,
}}
>
{icon}
</Box>
{typeof title === "string" ? (
<Typography variant="h6" fontWeight="medium" fontSize={18}>
{title}
</Typography>
) : (
title
)}
</Box>
{action}
</Box>
<Box
sx={{
flex: 1,
display: "flex",
flexDirection: "column",
p: noContentPadding ? 0 : 2,
...(minHeight && { minHeight }),
}}
>
{children}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,382 @@
import {
forwardRef,
useImperativeHandle,
useState,
useEffect,
useCallback,
useMemo,
ReactElement,
} from "react";
import { Box, useTheme } from "@mui/material";
import parseTraffic from "@/utils/parse-traffic";
import { useTranslation } from "react-i18next";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
AreaChart,
Area,
} from "recharts";
// 流量数据项接口
export interface ITrafficItem {
up: number;
down: number;
timestamp?: number;
}
// 组件对外暴露的方法
export interface EnhancedTrafficGraphRef {
appendData: (data: ITrafficItem) => void;
toggleStyle: () => void;
}
// 时间范围类型
type TimeRange = 1 | 5 | 10; // 分钟
/**
* 增强型流量图表组件
* 基于 Recharts 实现,支持线图和面积图两种模式
*/
export const EnhancedTrafficGraph = forwardRef<EnhancedTrafficGraphRef>(
(props, ref) => {
const theme = useTheme();
const { t } = useTranslation();
// 时间范围状态(默认10分钟)
const [timeRange, setTimeRange] = useState<TimeRange>(10);
// 根据时间范围计算保留的数据点数量
const getMaxPointsByTimeRange = useCallback(
(minutes: TimeRange): number => minutes * 60, // 每分钟60个点(每秒1个点)
[],
);
// 最大数据点数量 - 基于选择的时间范围
const MAX_BUFFER_SIZE = useMemo(
() => getMaxPointsByTimeRange(10),
[getMaxPointsByTimeRange],
);
// 图表样式line 或 area
const [chartStyle, setChartStyle] = useState<"line" | "area">("area");
// 创建一个明确的类型
type DataPoint = ITrafficItem & { name: string; timestamp: number };
// 完整数据缓冲区 - 保存10分钟的数据
const [dataBuffer, setDataBuffer] = useState<DataPoint[]>([]);
// 当前显示的数据点 - 根据选定的时间范围从缓冲区过滤
const dataPoints = useMemo(() => {
if (dataBuffer.length === 0) return [];
// 根据当前时间范围计算需要显示的点数
const pointsToShow = getMaxPointsByTimeRange(timeRange);
// 从缓冲区中获取最新的数据点
return dataBuffer.slice(-pointsToShow);
}, [dataBuffer, timeRange, getMaxPointsByTimeRange]);
// 颜色配置
const colors = useMemo(
() => ({
up: theme.palette.secondary.main,
down: theme.palette.primary.main,
grid: theme.palette.divider,
tooltip: theme.palette.background.paper,
text: theme.palette.text.primary,
}),
[theme],
);
// 切换时间范围
const handleTimeRangeClick = useCallback(() => {
setTimeRange((prevRange) => {
// 在1、5、10分钟之间循环切换
if (prevRange === 1) return 5;
if (prevRange === 5) return 10;
return 1;
});
}, []);
// 初始化空数据缓冲区
useEffect(() => {
// 生成10分钟的初始数据点
const now = Date.now();
const tenMinutesAgo = now - 10 * 60 * 1000;
// 创建600个点作为初始缓冲区
const initialBuffer: DataPoint[] = Array.from(
{ length: MAX_BUFFER_SIZE },
(_, index) => {
// 计算每个点的时间
const pointTime =
tenMinutesAgo + index * ((10 * 60 * 1000) / MAX_BUFFER_SIZE);
const date = new Date(pointTime);
return {
up: 0,
down: 0,
timestamp: pointTime,
name: date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
};
},
);
setDataBuffer(initialBuffer);
}, [MAX_BUFFER_SIZE]);
// 添加数据点方法
const appendData = useCallback((data: ITrafficItem) => {
// 安全处理数据
const safeData = {
up: typeof data.up === "number" && !isNaN(data.up) ? data.up : 0,
down:
typeof data.down === "number" && !isNaN(data.down) ? data.down : 0,
};
setDataBuffer((prev) => {
// 使用提供的时间戳或当前时间
const timestamp = data.timestamp || Date.now();
const date = new Date(timestamp);
// 带时间标签的新数据点
const newPoint: DataPoint = {
...safeData,
name: date.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}),
timestamp: timestamp,
};
// 更新缓冲区,保持最大长度
return [...prev.slice(1), newPoint];
});
}, []);
// 切换图表样式
const toggleStyle = useCallback(() => {
setChartStyle((prev) => (prev === "line" ? "area" : "line"));
}, []);
// 暴露方法给父组件
useImperativeHandle(
ref,
() => ({
appendData,
toggleStyle,
}),
[appendData, toggleStyle],
);
// 格式化工具提示内容
const formatTooltip = (value: number) => {
const [num, unit] = parseTraffic(value);
return [`${num} ${unit}/s`, ""];
};
// Y轴刻度格式化
const formatYAxis = (value: number) => {
const [num, unit] = parseTraffic(value);
return `${num}${unit}`;
};
// 格式化X轴标签
const formatXLabel = useCallback((value: string) => {
if (!value) return "";
// 只显示小时和分钟
const parts = value.split(":");
return `${parts[0]}:${parts[1]}`;
}, []);
// 获取当前时间范围文本
const getTimeRangeText = useCallback(() => {
return t("{{time}} Minutes", { time: timeRange });
}, [timeRange, t]);
// 渲染图表内的标签
const renderInnerLabels = () => (
<>
{/* 上传标签 - 右上角 */}
<text
x="98%"
y="7%"
textAnchor="end"
fill={colors.up}
fontSize={12}
fontWeight="bold"
>
{t("Upload")}
</text>
{/* 下载标签 - 右上角下方 */}
<text
x="98%"
y="16%"
textAnchor="end"
fill={colors.down}
fontSize={12}
fontWeight="bold"
>
{t("Download")}
</text>
</>
);
// 共享图表配置
const commonProps = {
data: dataPoints,
margin: { top: 10, right: 20, left: 0, bottom: 0 },
};
// 曲线类型 - 使用平滑曲线
const curveType = "basis";
// 共享图表子组件
const commonChildren = (
<>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={colors.grid}
opacity={0.3}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
interval="preserveStart"
tickFormatter={formatXLabel}
minTickGap={timeRange === 1 ? 40 : 80}
tickCount={Math.min(6, timeRange * 2)}
domain={["dataMin", "dataMax"]}
scale="auto"
/>
<YAxis
tickFormatter={formatYAxis}
tick={{ fontSize: 10, fill: colors.text }}
tickLine={{ stroke: colors.grid }}
axisLine={{ stroke: colors.grid }}
width={40}
domain={[0, "auto"]}
padding={{ top: 10, bottom: 0 }}
/>
<Tooltip
formatter={formatTooltip}
labelFormatter={(label) => `${t("Time")}: ${label}`}
contentStyle={{
backgroundColor: colors.tooltip,
borderColor: colors.grid,
borderRadius: 4,
}}
itemStyle={{ color: colors.text }}
isAnimationActive={false}
/>
{/* 可点击的时间范围标签 */}
<g
className="time-range-selector"
onClick={handleTimeRangeClick}
style={{ cursor: "pointer" }}
>
<text
x="1%"
y="6%"
textAnchor="start"
fill={theme.palette.text.secondary}
fontSize={11}
fontWeight="bold"
>
{getTimeRangeText()}
</text>
</g>
</>
);
// 渲染图表 - 线图或面积图
const renderChart = () => {
// 共享的线条/区域配置
const commonLineProps = {
dot: false,
strokeWidth: 2,
connectNulls: false,
activeDot: { r: 4, strokeWidth: 1 },
};
return chartStyle === "line" ? (
<LineChart {...commonProps}>
{commonChildren}
<Line
type="basis"
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
/>
<Line
type="basis"
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
/>
{renderInnerLabels()}
</LineChart>
) : (
<AreaChart {...commonProps}>
{commonChildren}
<Area
type="basis"
{...commonLineProps}
dataKey="up"
name={t("Upload")}
stroke={colors.up}
fill={colors.up}
fillOpacity={0.2}
/>
<Area
type="basis"
{...commonLineProps}
dataKey="down"
name={t("Download")}
stroke={colors.down}
fill={colors.down}
fillOpacity={0.3}
/>
{renderInnerLabels()}
</AreaChart>
);
};
return (
<Box
sx={{
width: "100%",
height: "100%",
position: "relative",
bgcolor: "action.hover",
borderRadius: 1,
padding: 1,
cursor: "pointer",
}}
onClick={toggleStyle}
>
<ResponsiveContainer width="100%" height="100%">
{renderChart()}
</ResponsiveContainer>
</Box>
);
},
);

View File

@@ -0,0 +1,394 @@
import { useState, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import {
Typography,
Paper,
alpha,
useTheme,
PaletteColor,
} from "@mui/material";
import Grid from "@mui/material/Grid2";
import {
ArrowUpwardRounded,
ArrowDownwardRounded,
MemoryRounded,
LinkRounded,
CloudUploadRounded,
CloudDownloadRounded,
} from "@mui/icons-material";
import {
EnhancedTrafficGraph,
EnhancedTrafficGraphRef,
ITrafficItem,
} from "./enhanced-traffic-graph";
import { useVisibility } from "@/hooks/use-visibility";
import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { createAuthSockette } from "@/utils/websocket";
import parseTraffic from "@/utils/parse-traffic";
import { getConnections, isDebugEnabled, gc } from "@/services/api";
import { ReactNode } from "react";
interface MemoryUsage {
inuse: number;
oslimit?: number;
}
interface TrafficStatData {
uploadTotal: number;
downloadTotal: number;
activeConnections: number;
}
interface StatCardProps {
icon: ReactNode;
title: string;
value: string | number;
unit: string;
color: "primary" | "secondary" | "error" | "warning" | "info" | "success";
onClick?: () => void;
}
// 全局变量类型定义
declare global {
interface Window {
animationFrameId?: number;
lastTrafficData?: {
up: number;
down: number;
};
}
}
export const EnhancedTrafficStats = () => {
const { t } = useTranslation();
const theme = useTheme();
const { clashInfo } = useClashInfo();
const { verge } = useVerge();
const trafficRef = useRef<EnhancedTrafficGraphRef>(null);
const pageVisible = useVisibility();
const [isDebug, setIsDebug] = useState(false);
const [trafficStats, setTrafficStats] = useState<TrafficStatData>({
uploadTotal: 0,
downloadTotal: 0,
activeConnections: 0,
});
// 是否显示流量图表
const trafficGraph = verge?.traffic_graph ?? true;
// 获取连接数据
const fetchConnections = async () => {
try {
const connections = await getConnections();
if (connections && connections.connections) {
const uploadTotal = connections.connections.reduce(
(sum, conn) => sum + conn.upload,
0,
);
const downloadTotal = connections.connections.reduce(
(sum, conn) => sum + conn.download,
0,
);
setTrafficStats({
uploadTotal,
downloadTotal,
activeConnections: connections.connections.length,
});
}
} catch (err) {
console.error("Failed to fetch connections:", err);
}
};
// 定期更新连接数据
useEffect(() => {
if (pageVisible) {
fetchConnections();
const intervalId = setInterval(fetchConnections, 5000);
return () => clearInterval(intervalId);
}
}, [pageVisible]);
// 检查是否支持调试
useEffect(() => {
isDebugEnabled().then((flag) => setIsDebug(flag));
}, []);
// 为流量数据和内存数据准备状态
const [trafficData, setTrafficData] = useState<ITrafficItem>({
up: 0,
down: 0,
});
const [memoryData, setMemoryData] = useState<MemoryUsage>({ inuse: 0 });
// 使用 WebSocket 连接获取流量数据
useEffect(() => {
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) as ITrafficItem;
if (
data &&
typeof data.up === "number" &&
typeof data.down === "number"
) {
setTrafficData({
up: isNaN(data.up) ? 0 : data.up,
down: isNaN(data.down) ? 0 : data.down,
});
if (trafficRef.current) {
const lastData = {
up: isNaN(data.up) ? 0 : data.up,
down: isNaN(data.down) ? 0 : data.down,
};
if (!window.lastTrafficData) {
window.lastTrafficData = { ...lastData };
}
trafficRef.current.appendData({
up: lastData.up,
down: lastData.down,
timestamp: Date.now(),
});
window.lastTrafficData = { ...lastData };
if (window.animationFrameId) {
cancelAnimationFrame(window.animationFrameId);
window.animationFrameId = undefined;
}
}
}
} catch (err) {
console.error("[Traffic] 解析数据错误:", err);
}
},
});
return () => socket.close();
}, [clashInfo, pageVisible]);
// 使用 WebSocket 连接获取内存数据
useEffect(() => {
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) as MemoryUsage;
if (data && typeof data.inuse === "number") {
setMemoryData({
inuse: isNaN(data.inuse) ? 0 : data.inuse,
oslimit: data.oslimit,
});
}
} catch (err) {
console.error("[Memory] 解析数据错误:", err);
}
},
});
return () => socket.close();
}, [clashInfo, pageVisible]);
// 解析流量数据
const [up, upUnit] = parseTraffic(trafficData.up);
const [down, downUnit] = parseTraffic(trafficData.down);
const [inuse, inuseUnit] = parseTraffic(memoryData.inuse);
const [uploadTotal, uploadTotalUnit] = parseTraffic(trafficStats.uploadTotal);
const [downloadTotal, downloadTotalUnit] = parseTraffic(
trafficStats.downloadTotal,
);
// 获取调色板颜色
const getColorFromPalette = (colorName: string) => {
const palette = theme.palette;
if (
colorName in palette &&
palette[colorName as keyof typeof palette] &&
"main" in (palette[colorName as keyof typeof palette] as PaletteColor)
) {
return (palette[colorName as keyof typeof palette] as PaletteColor).main;
}
return palette.primary.main;
};
// 统计卡片组件
const CompactStatCard = ({
icon,
title,
value,
unit,
color,
}: StatCardProps) => (
<Paper
elevation={0}
sx={{
display: "flex",
alignItems: "center",
borderRadius: 2,
bgcolor: alpha(getColorFromPalette(color), 0.05),
border: `1px solid ${alpha(getColorFromPalette(color), 0.15)}`,
//height: "80px",
padding: "8px",
transition: "all 0.2s ease-in-out",
cursor: "pointer",
"&:hover": {
bgcolor: alpha(getColorFromPalette(color), 0.1),
border: `1px solid ${alpha(getColorFromPalette(color), 0.3)}`,
boxShadow: `0 4px 8px rgba(0,0,0,0.05)`,
},
}}
>
{/* 图标容器 */}
<Grid
component="div"
sx={{
mr: 1,
ml: "2px",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
bgcolor: alpha(getColorFromPalette(color), 0.1),
color: getColorFromPalette(color),
}}
>
{icon}
</Grid>
{/* 文本内容 */}
<Grid component="div" sx={{ flexGrow: 1, minWidth: 0 }}>
<Typography variant="caption" color="text.secondary" noWrap>
{title}
</Typography>
<Grid component="div" sx={{ display: "flex", alignItems: "baseline" }}>
<Typography variant="body1" fontWeight="bold" noWrap sx={{ mr: 0.5 }}>
{value}
</Typography>
<Typography variant="caption" color="text.secondary">
{unit}
</Typography>
</Grid>
</Grid>
</Paper>
);
// 渲染流量图表
const renderTrafficGraph = () => {
if (!trafficGraph || !pageVisible) return null;
return (
<Paper
elevation={0}
sx={{
height: 130,
cursor: "pointer",
border: `1px solid ${alpha(theme.palette.divider, 0.2)}`,
borderRadius: 2,
overflow: "hidden",
}}
onClick={() => trafficRef.current?.toggleStyle()}
>
<div style={{ height: "100%", position: "relative" }}>
<EnhancedTrafficGraph ref={trafficRef} />
{isDebug && (
<div
style={{
position: "absolute",
top: "2px",
left: "2px",
zIndex: 10,
backgroundColor: "rgba(0,0,0,0.5)",
color: "white",
fontSize: "8px",
padding: "2px 4px",
borderRadius: "4px",
}}
>
DEBUG: {!!trafficRef.current ? "图表已初始化" : "图表未初始化"}
<br />
{new Date().toISOString().slice(11, 19)}
</div>
)}
</div>
</Paper>
);
};
// 统计卡片配置
const statCards = [
{
icon: <ArrowUpwardRounded fontSize="small" />,
title: t("Upload Speed"),
value: up,
unit: `${upUnit}/s`,
color: "secondary" as const,
},
{
icon: <ArrowDownwardRounded fontSize="small" />,
title: t("Download Speed"),
value: down,
unit: `${downUnit}/s`,
color: "primary" as const,
},
{
icon: <LinkRounded fontSize="small" />,
title: t("Active Connections"),
value: trafficStats.activeConnections,
unit: "",
color: "success" as const,
},
{
icon: <CloudUploadRounded fontSize="small" />,
title: t("Uploaded"),
value: uploadTotal,
unit: uploadTotalUnit,
color: "secondary" as const,
},
{
icon: <CloudDownloadRounded fontSize="small" />,
title: t("Downloaded"),
value: downloadTotal,
unit: downloadTotalUnit,
color: "primary" as const,
},
{
icon: <MemoryRounded fontSize="small" />,
title: t("Memory Usage"),
value: inuse,
unit: inuseUnit,
color: "error" as const,
onClick: isDebug ? async () => await gc() : undefined,
},
];
return (
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
<Grid size={12}>
{/* 流量图表区域 */}
{renderTrafficGraph()}
</Grid>
{/* 统计卡片区域 */}
{statCards.map((card, index) => (
<Grid size={4}>
<CompactStatCard {...card} />
</Grid>
))}
</Grid>
);
};

View File

@@ -0,0 +1,297 @@
import { useTranslation } from "react-i18next";
import {
Box,
Typography,
Button,
Stack,
LinearProgress,
alpha,
useTheme,
Link,
keyframes,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
import {
CloudUploadOutlined,
StorageOutlined,
UpdateOutlined,
DnsOutlined,
SpeedOutlined,
EventOutlined,
LaunchOutlined,
} from "@mui/icons-material";
import dayjs from "dayjs";
import parseTraffic from "@/utils/parse-traffic";
import { useState } from "react";
import { openWebUrl, updateProfile } from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Notice } from "@/components/base";
import { EnhancedCard } from "./enhanced-card";
// 定义旋转动画
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
// 辅助函数解析URL和过期时间
function parseUrl(url?: string) {
if (!url) return "-";
if (url.startsWith("http")) return new URL(url).host;
return "local";
}
function parseExpire(expire?: number) {
if (!expire) return "-";
return dayjs(expire * 1000).format("YYYY-MM-DD");
}
// 使用类型定义,而不是导入
interface ProfileExtra {
upload: number;
download: number;
total: number;
expire: number;
}
export interface ProfileItem {
uid: string;
type?: "local" | "remote" | "merge" | "script";
name?: string;
desc?: string;
file?: string;
url?: string;
updated?: number;
extra?: ProfileExtra;
home?: string;
option?: any; // 添加option以兼容原始类型
}
export interface HomeProfileCardProps {
current: ProfileItem | null | undefined;
}
export const HomeProfileCard = ({ current }: HomeProfileCardProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const theme = useTheme();
// 更新当前订阅
const [updating, setUpdating] = useState(false);
const onUpdateProfile = useLockFn(async () => {
if (!current?.uid) return;
setUpdating(true);
try {
await updateProfile(current.uid);
Notice.success(t("Update subscription successfully"));
} catch (err: any) {
Notice.error(err?.message || err.toString());
} finally {
setUpdating(false);
}
});
// 导航到订阅页面
const goToProfiles = () => {
navigate("/profile");
};
return (
<EnhancedCard
title={
current ? (
current.home ? (
<Link
component="button"
variant="h6"
fontWeight="medium"
fontSize={18}
onClick={() => current.home && openWebUrl(current.home)}
sx={{
display: "inline-flex",
alignItems: "center",
color: "inherit",
textDecoration: "none",
}}
>
{current.name}
<LaunchOutlined
fontSize="inherit"
sx={{ ml: 0.5, fontSize: "0.8rem", opacity: 0.7 }}
/>
</Link>
) : (
current.name
)
) : (
t("Profiles")
)
}
icon={<CloudUploadOutlined />}
iconColor="info"
action={
current && (
<Button
variant="outlined"
size="small"
onClick={goToProfiles}
endIcon={<StorageOutlined fontSize="small" />}
sx={{ borderRadius: 1.5 }}
>
{t("Label-Profiles")}
</Button>
)
}
>
{current ? (
// 已导入订阅,显示详情
<Box>
<Stack spacing={2}>
{current.url && (
<Stack direction="row" alignItems="center" spacing={1}>
<DnsOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{t("From")}:{" "}
{current.home ? (
<Link
component="button"
fontWeight="medium"
onClick={() => current.home && openWebUrl(current.home)}
sx={{ display: "inline-flex", alignItems: "center" }}
>
{parseUrl(current.url)}
<LaunchOutlined
fontSize="inherit"
sx={{ ml: 0.5, fontSize: "0.8rem", opacity: 0.7 }}
/>
</Link>
) : (
<Box component="span" fontWeight="medium">
{parseUrl(current.url)}
</Box>
)}
</Typography>
</Stack>
)}
{current.updated && (
<Stack direction="row" alignItems="center" spacing={1}>
<UpdateOutlined
fontSize="small"
color="action"
sx={{
cursor: "pointer",
animation: updating
? `${round} 1.5s linear infinite`
: "none",
}}
onClick={onUpdateProfile}
/>
<Typography
variant="body2"
color="text.secondary"
sx={{ cursor: "pointer" }}
onClick={onUpdateProfile}
>
{t("Update Time")}:{" "}
<Box component="span" fontWeight="medium">
{dayjs(current.updated * 1000).format("YYYY-MM-DD HH:mm")}
</Box>
</Typography>
</Stack>
)}
{current.extra && (
<>
<Stack direction="row" alignItems="center" spacing={1}>
<SpeedOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{t("Used / Total")}:{" "}
<Box component="span" fontWeight="medium">
{parseTraffic(
current.extra.upload + current.extra.download,
)}{" "}
/ {parseTraffic(current.extra.total)}
</Box>
</Typography>
</Stack>
{current.extra.expire > 0 && (
<Stack direction="row" alignItems="center" spacing={1}>
<EventOutlined fontSize="small" color="action" />
<Typography variant="body2" color="text.secondary">
{t("Expire Time")}:{" "}
<Box component="span" fontWeight="medium">
{parseExpire(current.extra.expire)}
</Box>
</Typography>
</Stack>
)}
<Box sx={{ mt: 1 }}>
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0.5, display: "block" }}
>
{Math.min(
Math.round(
((current.extra.download + current.extra.upload) *
100) /
(current.extra.total + 0.01),
) + 1,
100,
)}
%
</Typography>
<LinearProgress
variant="determinate"
value={Math.min(
Math.round(
((current.extra.download + current.extra.upload) *
100) /
(current.extra.total + 0.01),
) + 1,
100,
)}
sx={{
height: 8,
borderRadius: 4,
backgroundColor: alpha(theme.palette.primary.main, 0.12),
}}
/>
</Box>
</>
)}
</Stack>
</Box>
) : (
// 未导入订阅,显示导入按钮
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
py: 2.4,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
borderRadius: 2,
}}
onClick={goToProfiles}
>
<CloudUploadOutlined
sx={{ fontSize: 60, color: "primary.main", mb: 2 }}
/>
<Typography variant="h6" gutterBottom>
{t("Import")} {t("Profiles")}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Click to import subscription")}
</Typography>
</Box>
)}
</EnhancedCard>
);
};

View File

@@ -0,0 +1,298 @@
import { useTranslation } from "react-i18next";
import {
Box,
Typography,
Button,
Skeleton,
IconButton,
useTheme,
} from "@mui/material";
import {
LocationOnOutlined,
RefreshOutlined,
VisibilityOutlined,
VisibilityOffOutlined,
} from "@mui/icons-material";
import { EnhancedCard } from "./enhanced-card";
import { getIpInfo } from "@/services/api";
import { useState, useEffect, useCallback } from "react";
// 定义刷新时间(秒)
const IP_REFRESH_SECONDS = 300;
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const theme = useTheme();
const [ipInfo, setIpInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showIp, setShowIp] = useState(false);
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
// 获取IP信息
const fetchIpInfo = useCallback(async () => {
try {
setLoading(true);
setError("");
const data = await getIpInfo();
setIpInfo(data);
setCountdown(IP_REFRESH_SECONDS);
} catch (err: any) {
setError(err.message || t("Failed to get IP info"));
} finally {
setLoading(false);
}
}, [t]);
// 组件加载时获取IP信息
useEffect(() => {
fetchIpInfo();
}, [fetchIpInfo]);
// 倒计时自动刷新
useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
fetchIpInfo();
return IP_REFRESH_SECONDS;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [fetchIpInfo]);
// 刷新按钮点击处理
const handleRefresh = () => {
fetchIpInfo();
};
// 切换显示/隐藏IP
const toggleShowIp = () => {
setShowIp(!showIp);
};
// 获取国旗表情
const getCountryFlag = (countryCode: string) => {
if (!countryCode) return "";
const codePoints = countryCode
.toUpperCase()
.split("")
.map((char) => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
// 信息项组件 - 默认不换行,但在需要时可以换行
const InfoItem = ({ label, value }: { label: string; value: string }) => (
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
<Typography
variant="body2"
color="text.secondary"
sx={{
minwidth: 60,
mr: 0.5,
flexShrink: 0,
textAlign: "right",
}}
>
{label}:
</Typography>
<Typography
variant="body2"
sx={{
ml: 0.5,
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-word",
whiteSpace: "normal",
flexGrow: 1, // 让内容占用剩余空间
}}
>
{value || t("Unknown")}
</Typography>
</Box>
);
return (
<EnhancedCard
title={t("IP Information")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={handleRefresh} disabled={loading}>
<RefreshOutlined />
</IconButton>
}
>
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
{loading ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Skeleton variant="text" width="60%" height={34} />
<Skeleton variant="text" width="80%" height={24} />
<Skeleton variant="text" width="70%" height={24} />
<Skeleton variant="text" width="50%" height={24} />
</Box>
) : error ? (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
color: "error.main",
}}
>
<Typography variant="body1" color="error">
{error}
</Typography>
<Button onClick={handleRefresh} sx={{ mt: 2 }}>
{t("Retry")}
</Button>
</Box>
) : (
<>
<Box
sx={{
display: "flex",
flexDirection: "row",
flex: 1,
overflow: "hidden",
}}
>
{/* 左侧国家和IP地址 */}
<Box sx={{ width: "40%", overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
mb: 1,
overflow: "hidden",
}}
>
<Box
component="span"
sx={{
fontSize: "1.5rem",
mr: 1,
display: "inline-block",
width: 28,
textAlign: "center",
flexShrink: 0,
}}
>
{getCountryFlag(ipInfo?.country_code)}
</Box>
<Typography
variant="subtitle1"
sx={{
fontWeight: "medium",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "100%",
}}
>
{ipInfo?.country || t("Unknown")}
</Typography>
</Box>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ flexShrink: 0 }}
>
{t("IP")}:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
ml: 1,
overflow: "hidden",
maxWidth: "calc(100% - 30px)",
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
fontSize: "0.75rem",
overflow: "hidden",
textOverflow: "ellipsis",
wordBreak: "break-all",
}}
>
{showIp ? ipInfo?.ip : "••••••••••"}
</Typography>
<IconButton size="small" onClick={toggleShowIp}>
{showIp ? (
<VisibilityOffOutlined fontSize="small" />
) : (
<VisibilityOutlined fontSize="small" />
)}
</IconButton>
</Box>
</Box>
<InfoItem
label={t("ASN")}
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
/>
</Box>
{/* 右侧组织、ISP和位置信息 */}
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem label={t("ISP")} value={ipInfo?.isp} />
<InfoItem label={t("ORG")} value={ipInfo?.asn_organization} />
<InfoItem
label={t("Location")}
value={[ipInfo?.city, ipInfo?.region]
.filter(Boolean)
.join(", ")}
/>
<InfoItem label={t("Timezone")} value={ipInfo?.timezone} />
</Box>
</Box>
<Box
sx={{
mt: "auto",
pt: 0.5,
borderTop: 1,
borderColor: "divider",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
opacity: 0.7,
fontSize: "0.7rem",
}}
>
<Typography variant="caption">
{t("Auto refresh")}: {countdown}s
</Typography>
<Typography
variant="caption"
sx={{
textOverflow: "ellipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "}
{ipInfo?.latitude?.toFixed(2)}
</Typography>
</Box>
</>
)}
</Box>
</EnhancedCard>
);
};

View File

@@ -0,0 +1,280 @@
import { useTranslation } from "react-i18next";
import {
Box,
Typography,
Stack,
Paper,
Tooltip,
alpha,
useTheme,
Button,
Fade,
} from "@mui/material";
import { useState, useEffect } from "react";
import ProxyControlSwitches from "@/components/shared/ProxyControlSwitches";
import { Notice } from "@/components/base";
import {
LanguageRounded,
ComputerRounded,
TroubleshootRounded,
HelpOutlineRounded,
} from "@mui/icons-material";
import useSWR from "swr";
import {
getSystemProxy,
getAutotemProxy,
getRunningMode,
} from "@/services/cmds";
export const ProxyTunCard = () => {
const { t } = useTranslation();
const theme = useTheme();
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>("system");
// 获取代理状态信息
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy);
const { data: runningMode } = useSWR("getRunningMode", getRunningMode);
// 是否以sidecar模式运行
const isSidecarMode = runningMode === "sidecar";
// 处理错误
const handleError = (err: Error) => {
setError(err.message);
Notice.error(err.message || err.toString(), 3000);
};
// 用户提示文本
const getTabDescription = (tab: string) => {
switch (tab) {
case "system":
return sysproxy?.enable
? t("System Proxy Enabled")
: t("System Proxy Disabled");
case "tun":
return isSidecarMode
? t("TUN Mode Service Required")
: t("TUN Mode Intercept Info");
default:
return "";
}
};
return (
<Box sx={{ display: "flex", flexDirection: "column", width: "100%" }}>
{/* 选项卡 */}
<Stack
direction="row"
spacing={1}
sx={{
display: "flex",
justifyContent: "center",
position: "relative",
zIndex: 2,
}}
>
<Paper
elevation={activeTab === "system" ? 2 : 0}
onClick={() => setActiveTab("system")}
sx={{
cursor: "pointer",
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 1,
bgcolor:
activeTab === "system" ? "primary.main" : "background.paper",
color:
activeTab === "system" ? "primary.contrastText" : "text.primary",
borderRadius: 1.5,
flex: 1,
maxWidth: 160,
transition: "all 0.2s ease-in-out",
position: "relative",
"&:hover": {
transform: "translateY(-1px)",
boxShadow: 1,
},
"&:after":
activeTab === "system"
? {
content: '""',
position: "absolute",
bottom: -9,
left: "50%",
width: 2,
height: 9,
bgcolor: "primary.main",
transform: "translateX(-50%)",
}
: {},
}}
>
<ComputerRounded fontSize="small" />
<Typography
variant="body2"
sx={{ fontWeight: activeTab === "system" ? 600 : 400 }}
>
{t("System Proxy")}
</Typography>
{sysproxy?.enable && (
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: activeTab === "system" ? "#fff" : "success.main",
position: "absolute",
top: 8,
right: 8,
}}
/>
)}
</Paper>
<Paper
elevation={activeTab === "tun" ? 2 : 0}
onClick={() => setActiveTab("tun")}
sx={{
cursor: "pointer",
px: 2,
py: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 1,
bgcolor: activeTab === "tun" ? "primary.main" : "background.paper",
color:
activeTab === "tun" ? "primary.contrastText" : "text.primary",
borderRadius: 1.5,
flex: 1,
maxWidth: 160,
transition: "all 0.2s ease-in-out",
position: "relative",
"&:hover": {
transform: "translateY(-1px)",
boxShadow: 1,
},
"&:after":
activeTab === "tun"
? {
content: '""',
position: "absolute",
bottom: -9,
left: "50%",
width: 2,
height: 9,
bgcolor: "primary.main",
transform: "translateX(-50%)",
}
: {},
}}
>
<TroubleshootRounded fontSize="small" />
<Typography
variant="body2"
sx={{ fontWeight: activeTab === "tun" ? 600 : 400 }}
>
{t("Tun Mode")}
</Typography>
</Paper>
</Stack>
{/* 说明文本区域 */}
<Box
sx={{
width: "100%",
my: 1,
position: "relative",
display: "flex",
justifyContent: "center",
overflow: "visible",
}}
>
{activeTab === "system" && (
<Fade in={true} timeout={200}>
<Typography
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 0.5,
wordBreak: "break-word",
hyphens: "auto",
}}
>
{getTabDescription("system")}
<Tooltip title={t("System Proxy Info")}>
<HelpOutlineRounded
sx={{ fontSize: 14, opacity: 0.7, flexShrink: 0 }}
/>
</Tooltip>
</Typography>
</Fade>
)}
{activeTab === "tun" && (
<Fade in={true} timeout={200}>
<Typography
variant="caption"
component="div"
sx={{
width: "95%",
textAlign: "center",
color: "text.secondary",
p: 0.8,
borderRadius: 1,
borderColor: "primary.main",
borderWidth: 1,
borderStyle: "solid",
backgroundColor: "background.paper",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 0.5,
wordBreak: "break-word",
hyphens: "auto",
}}
>
{getTabDescription("tun")}
<Tooltip title={t("Tun Mode Info")}>
<HelpOutlineRounded
sx={{ fontSize: 14, opacity: 0.7, flexShrink: 0 }}
/>
</Tooltip>
</Typography>
</Fade>
)}
</Box>
{/* 控制开关部分 */}
<Box
sx={{
mt: 0,
p: 1,
bgcolor: alpha(theme.palette.primary.main, 0.04),
borderRadius: 2,
}}
>
<ProxyControlSwitches
onError={handleError}
label={activeTab === "system" ? t("System Proxy") : t("Tun Mode")}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,240 @@
import { useTranslation } from "react-i18next";
import { Typography, Stack, Divider, Chip, IconButton } from "@mui/material";
import { InfoOutlined, SettingsOutlined } from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge";
import { EnhancedCard } from "./enhanced-card";
import useSWR from "swr";
import { getRunningMode, getSystemInfo, installService } from "@/services/cmds";
import { useNavigate } from "react-router-dom";
import { version as appVersion } from "@root/package.json";
import { useEffect, useState } from "react";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useLockFn } from "ahooks";
import { Notice } from "@/components/base";
export const SystemInfoCard = () => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
const navigate = useNavigate();
// 获取运行模式
const { data: runningMode = "sidecar", mutate: mutateRunningMode } = useSWR(
"getRunningMode",
getRunningMode,
);
// 获取系统信息
const [osInfo, setOsInfo] = useState<string>("");
useEffect(() => {
getSystemInfo()
.then((info) => {
const lines = info.split("\n");
if (lines.length > 0) {
// 提取系统名称和版本信息
const sysNameLine = lines[0]; // System Name: xxx
const sysVersionLine = lines[1]; // System Version: xxx
const sysName = sysNameLine.split(": ")[1] || "";
const sysVersion = sysVersionLine.split(": ")[1] || "";
setOsInfo(`${sysName} ${sysVersion}`);
}
})
.catch((err) => {
console.error("Error getting system info:", err);
});
}, []);
// 获取最后检查更新时间
const [lastCheckUpdate, setLastCheckUpdate] = useState<string>("-");
// 在组件挂载时检查本地存储中的最后更新时间
useEffect(() => {
// 获取最后检查更新时间
const lastCheck = localStorage.getItem("last_check_update");
if (lastCheck) {
try {
const timestamp = parseInt(lastCheck, 10);
if (!isNaN(timestamp)) {
const date = new Date(timestamp);
setLastCheckUpdate(date.toLocaleString());
}
} catch (e) {
console.error("Error parsing last check update time", e);
}
} else if (verge?.auto_check_update) {
// 如果启用了自动检查更新但没有最后检查时间记录,则触发一次检查
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
setLastCheckUpdate(new Date(now).toLocaleString());
// 延迟执行检查更新,避免在应用启动时立即执行
setTimeout(() => {
checkUpdate().catch((e) => console.error("Error checking update:", e));
}, 5000);
}
}, [verge?.auto_check_update]);
// 监听 checkUpdate 调用并更新时间
useSWR(
"checkUpdate",
async () => {
// 更新最后检查时间
const now = Date.now();
localStorage.setItem("last_check_update", now.toString());
setLastCheckUpdate(new Date(now).toLocaleString());
// 实际执行检查更新
return await checkUpdate();
},
{
revalidateOnFocus: false,
refreshInterval: 24 * 60 * 60 * 1000, // 每天检查一次更新
dedupingInterval: 60 * 60 * 1000, // 1小时内不重复检查,
isPaused: () => !(verge?.auto_check_update ?? true), // 根据 auto_check_update 设置决定是否启用
},
);
// 导航到设置页面
const goToSettings = () => {
navigate("/settings");
};
// 切换自启动状态
const toggleAutoLaunch = async () => {
try {
if (!verge) return;
// 将当前的启动状态取反
await patchVerge({ enable_auto_launch: !verge.enable_auto_launch });
} catch (err) {
console.error("切换开机自启动状态失败:", err);
}
};
// 安装系统服务
const onInstallService = useLockFn(async () => {
try {
Notice.info(t("Installing Service..."), 1000);
await installService();
Notice.success(t("Service Installed Successfully"), 2000);
// 重新获取运行模式
await mutateRunningMode();
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
}
});
// 点击运行模式
const handleRunningModeClick = () => {
if (runningMode === "sidecar") {
onInstallService();
}
};
// 检查更新
const onCheckUpdate = async () => {
try {
const info = await checkUpdate();
if (!info?.available) {
Notice.success(t("Currently on the Latest Version"));
} else {
Notice.info(t("Update Available"), 2000);
goToSettings(); // 跳转到设置页面查看更新
}
} catch (err: any) {
Notice.error(err.message || err.toString());
}
};
return (
<EnhancedCard
title={t("System Info")}
icon={<InfoOutlined />}
iconColor="error"
action={
<IconButton size="small" onClick={goToSettings} title={t("Settings")}>
<SettingsOutlined fontSize="small" />
</IconButton>
}
>
{verge && (
<Stack spacing={1.5}>
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("OS Info")}
</Typography>
<Typography variant="body2" fontWeight="medium">
{osInfo}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Auto Launch")}
</Typography>
<Chip
size="small"
label={verge.enable_auto_launch ? t("Enabled") : t("Disabled")}
color={verge.enable_auto_launch ? "success" : "default"}
variant={verge.enable_auto_launch ? "filled" : "outlined"}
onClick={toggleAutoLaunch}
sx={{ cursor: "pointer" }}
/>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Running Mode")}
</Typography>
<Typography
variant="body2"
fontWeight="medium"
onClick={handleRunningModeClick}
sx={{
cursor: runningMode === "sidecar" ? "pointer" : "default",
textDecoration:
runningMode === "sidecar" ? "underline" : "none",
"&:hover": {
opacity: runningMode === "sidecar" ? 0.7 : 1,
},
}}
>
{runningMode === "service"
? t("Service Mode")
: t("Sidecar Mode")}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Last Check Update")}
</Typography>
<Typography
variant="body2"
fontWeight="medium"
onClick={onCheckUpdate}
sx={{
cursor: "pointer",
textDecoration: "underline",
"&:hover": {
opacity: 0.7,
},
}}
>
{lastCheckUpdate}
</Typography>
</Stack>
<Divider />
<Stack direction="row" justifyContent="space-between">
<Typography variant="body2" color="text.secondary">
{t("Verge Version")}
</Typography>
<Typography variant="body2" fontWeight="medium">
v{appVersion}
</Typography>
</Stack>
</Stack>
)}
</EnhancedCard>
);
};

View File

@@ -0,0 +1,172 @@
import { useEffect, useRef } from "react";
import { useVerge } from "@/hooks/use-verge";
import { Box, IconButton, Tooltip, alpha, styled } from "@mui/material";
import Grid from "@mui/material/Grid2";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";
import { useTranslation } from "react-i18next";
import { TestViewer, TestViewerRef } from "@/components/test/test-viewer";
import { TestItem } from "@/components/test/test-item";
import { emit } from "@tauri-apps/api/event";
import { nanoid } from "nanoid";
import { Add, NetworkCheck } from "@mui/icons-material";
import { EnhancedCard } from "./enhanced-card";
// test icons
import apple from "@/assets/image/test/apple.svg?raw";
import github from "@/assets/image/test/github.svg?raw";
import google from "@/assets/image/test/google.svg?raw";
import youtube from "@/assets/image/test/youtube.svg?raw";
// 自定义滚动条样式
const ScrollBox = styled(Box)(({ theme }) => ({
maxHeight: "180px",
overflowY: "auto",
overflowX: "hidden",
"&::-webkit-scrollbar": {
width: "6px",
},
"&::-webkit-scrollbar-thumb": {
backgroundColor: alpha(theme.palette.text.primary, 0.2),
borderRadius: "3px",
},
}));
export const TestCard = () => {
const { t } = useTranslation();
const sensors = useSensors(useSensor(PointerSensor));
const { verge, mutateVerge, patchVerge } = useVerge();
// test list
const testList = verge?.test_list ?? [
{
uid: nanoid(),
name: "Apple",
url: "https://www.apple.com",
icon: apple,
},
{
uid: nanoid(),
name: "GitHub",
url: "https://www.github.com",
icon: github,
},
{
uid: nanoid(),
name: "Google",
url: "https://www.google.com",
icon: google,
},
{
uid: nanoid(),
name: "Youtube",
url: "https://www.youtube.com",
icon: youtube,
},
];
const onTestListItemChange = (
uid: string,
patch?: Partial<IVergeTestItem>,
) => {
if (patch) {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
mutateVerge({ ...verge, test_list: newList }, false);
} else {
mutateVerge();
}
};
const onDeleteTestListItem = (uid: string) => {
const newList = testList.filter((x) => x.uid !== uid);
patchVerge({ test_list: newList });
mutateVerge({ ...verge, test_list: newList }, false);
};
const onDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
let old_index = testList.findIndex((x) => x.uid === active.id);
let new_index = testList.findIndex((x) => x.uid === over.id);
if (old_index >= 0 && new_index >= 0) {
const newList = [...testList];
const [removed] = newList.splice(old_index, 1);
newList.splice(new_index, 0, removed);
await mutateVerge({ ...verge, test_list: newList }, false);
await patchVerge({ test_list: newList });
}
}
};
useEffect(() => {
if (!verge) return;
if (!verge?.test_list) {
patchVerge({ test_list: testList });
}
}, [verge]);
const viewerRef = useRef<TestViewerRef>(null);
return (
<EnhancedCard
title={t("Website Tests")}
icon={<NetworkCheck />}
action={
<Box sx={{ display: "flex", gap: 1 }}>
<Tooltip title={t("Test All")} arrow>
<IconButton size="small" onClick={() => emit("verge://test-all")}>
<NetworkCheck fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title={t("Create Test")} arrow>
<IconButton
size="small"
onClick={() => viewerRef.current?.create()}
>
<Add fontSize="small" />
</IconButton>
</Tooltip>
</Box>
}
>
<ScrollBox>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<Grid container spacing={1} columns={12}>
<SortableContext items={testList.map((x) => x.uid)}>
{testList.map((item) => (
<Grid key={item.uid} size={3}>
<TestItem
id={item.uid}
itemData={item}
onEdit={() => viewerRef.current?.edit(item)}
onDelete={onDeleteTestListItem}
/>
</Grid>
))}
</SortableContext>
</Grid>
</DndContext>
</ScrollBox>
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
</EnhancedCard>
);
};

View File

@@ -81,7 +81,7 @@ export const ProfileItem = (props: Props) => {
const expire = parseExpire(extra?.expire);
const progress = Math.min(
Math.round(((download + upload) * 100) / (total + 0.01)) + 1,
100
100,
);
const loading = loadingCache[itemData.uid] ?? false;
@@ -202,11 +202,12 @@ export const ProfileItem = (props: Props) => {
try {
await updateProfile(itemData.uid, option);
Notice.success(t("Update subscription successfully"));
mutate("getProfiles");
} catch (err: any) {
const errmsg = err?.message || err.toString();
Notice.error(
errmsg.replace(/error sending request for url (\S+?): /, "")
errmsg.replace(/error sending request for url (\S+?): /, ""),
);
} finally {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));

View File

@@ -57,7 +57,7 @@ export const ProxyHead = (props: Props) => {
<IconButton
size="small"
color="inherit"
title={t("Location")}
title={t("locate")}
onClick={props.onLocation}
>
<MyLocationRounded />

View File

@@ -0,0 +1,296 @@
import { useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
import {
SettingsRounded,
PlayCircleOutlineRounded,
PauseCircleOutlineRounded,
BuildRounded,
} from "@mui/icons-material";
import {
Box,
Button,
Tooltip,
Typography,
alpha,
useTheme,
} from "@mui/material";
import { DialogRef, Notice, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { GuardState } from "@/components/setting/mods/guard-state";
import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer";
import { TunViewer } from "@/components/setting/mods/tun-viewer";
import { useVerge } from "@/hooks/use-verge";
import {
getSystemProxy,
getAutotemProxy,
getRunningMode,
installService,
} from "@/services/cmds";
import { useLockFn } from "ahooks";
import { SettingItem } from "@/components/setting/mods/setting-comp";
interface ProxySwitchProps {
label?: string;
onError?: (err: Error) => void;
}
/**
* 可复用的代理控制开关组件
* 包含 Tun Mode 和 System Proxy 的开关功能
*/
const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge();
const theme = useTheme();
const { data: sysproxy } = useSWR("getSystemProxy", getSystemProxy);
const { data: autoproxy } = useSWR("getAutotemProxy", getAutotemProxy);
const { data: runningMode, mutate: mutateRunningMode } = useSWR(
"getRunningMode",
getRunningMode,
);
// 是否以sidecar模式运行
const isSidecarMode = runningMode === "sidecar";
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
const { enable_tun_mode, enable_system_proxy, proxy_auto_config } =
verge ?? {};
// 确定当前显示哪个开关
const isSystemProxyMode = label === t("System Proxy") || !label;
const isTunMode = label === t("Tun Mode");
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
const updateProxyStatus = async () => {
// 等待一小段时间让系统代理状态变化
await new Promise((resolve) => setTimeout(resolve, 100));
await mutate("getSystemProxy");
await mutate("getAutotemProxy");
};
// 安装系统服务
const onInstallService = useLockFn(async () => {
try {
Notice.info(t("Installing Service..."), 1000);
await installService();
Notice.success(t("Service Installed Successfully"), 2000);
// 重新获取运行模式
await mutateRunningMode();
} catch (err: any) {
Notice.error(err.message || err.toString(), 3000);
}
});
return (
<Box>
{label && (
<Box
sx={{
fontSize: "15px",
fontWeight: "500",
mb: 0.5,
display: "none", // 隐藏标签,因为在父组件中已经有标签了
}}
>
{label}
</Box>
)}
{/* 仅显示当前选中的开关 */}
{isSystemProxyMode && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 1,
borderRadius: 1.5,
bgcolor: enable_system_proxy
? alpha(theme.palette.success.main, 0.07)
: "transparent",
transition: "background-color 0.3s",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{proxy_auto_config ? (
autoproxy?.enable ? (
<PlayCircleOutlineRounded
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
/>
) : (
<PauseCircleOutlineRounded
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
/>
)
) : sysproxy?.enable ? (
<PlayCircleOutlineRounded
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
/>
) : (
<PauseCircleOutlineRounded
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
/>
)}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
{t("System Proxy")}
</Typography>
{/* <Typography variant="caption" color="text.secondary">
{sysproxy?.enable
? t("Proxy is active")
: t("Enable this for most users")
}
</Typography> */}
</Box>
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={t("System Proxy Info")} arrow>
<Box
sx={{
mr: 1,
color: "text.secondary",
"&:hover": { color: "primary.main" },
cursor: "pointer",
}}
onClick={() => sysproxyRef.current?.open()}
>
<SettingsRounded fontSize="small" />
</Box>
</Tooltip>
<GuardState
value={enable_system_proxy ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={async (e) => {
await patchVerge({ enable_system_proxy: e });
await updateProxyStatus();
}}
>
<Switch edge="end" />
</GuardState>
</Box>
</Box>
)}
{isTunMode && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
p: 1,
borderRadius: 1.5,
bgcolor: enable_tun_mode
? alpha(theme.palette.success.main, 0.07)
: "transparent",
opacity: isSidecarMode ? 0.6 : 1,
transition: "background-color 0.3s",
}}
>
<Box sx={{ display: "flex", alignItems: "center" }}>
{enable_tun_mode ? (
<PlayCircleOutlineRounded
sx={{ color: "success.main", mr: 1.5, fontSize: 28 }}
/>
) : (
<PauseCircleOutlineRounded
sx={{ color: "text.disabled", mr: 1.5, fontSize: 28 }}
/>
)}
<Box>
<Typography
variant="subtitle1"
sx={{ fontWeight: 500, fontSize: "15px" }}
>
{t("Tun Mode")}
</Typography>
{/* <Typography variant="caption" color="text.secondary">
{isSidecarMode
? t("TUN requires Service Mode")
: t("For special applications")
}
</Typography> */}
</Box>
</Box>
<Box sx={{ display: "flex", alignItems: "center" }}>
{isSidecarMode && (
<Tooltip title={t("Install Service")} arrow>
<Button
variant="outlined"
color="primary"
size="small"
onClick={onInstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<BuildRounded fontSize="small" />
</Button>
</Tooltip>
)}
<Tooltip title={t("Tun Mode Info")} arrow>
<Box
sx={{
mr: 1,
color: "text.secondary",
"&:hover": { color: "primary.main" },
cursor: "pointer",
}}
onClick={() => tunRef.current?.open()}
>
<SettingsRounded fontSize="small" />
</Box>
</Tooltip>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
// 当在sidecar模式下禁用切换
if (isSidecarMode) return;
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
// 当在sidecar模式下禁用切换
if (isSidecarMode) {
Notice.error(t("TUN requires Service Mode"), 2000);
return Promise.reject(
new Error(t("TUN requires Service Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" disabled={isSidecarMode} />
</GuardState>
</Box>
</Box>
)}
{/* 引用对话框组件 */}
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
</Box>
);
};
export default ProxyControlSwitches;

View File

@@ -4,7 +4,8 @@ export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => {
const { mode, primary, text } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = mode === "light" ? "#ffffff" : "#282A36";
const backgroundColor =
mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08);
const color = {
"light-true": text.secondary,
@@ -27,11 +28,17 @@ export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => {
cursor: "pointer",
textAlign: "left",
borderRadius: 8,
boxShadow: theme.shadows[2],
boxShadow: theme.shadows[1],
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
transition: "background-color 0.3s, box-shadow 0.3s",
"&:hover": {
backgroundColor:
mode === "light" ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15),
boxShadow: theme.shadows[2],
},
};
});

View File

@@ -3,15 +3,7 @@ import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Box,
Typography,
Divider,
MenuItem,
Menu,
styled,
alpha,
} from "@mui/material";
import { Box, Divider, MenuItem, Menu, styled, alpha } from "@mui/material";
import { BaseLoading } from "@/components/base";
import { LanguageRounded } from "@mui/icons-material";
import { Notice } from "@/components/base";
@@ -149,11 +141,7 @@ export const TestItem = (props: Props) => {
</Box>
)}
<Box sx={{ display: "flex", justifyContent: "center" }}>
<Typography variant="h6" component="h2" noWrap title={name}>
{name}
</Typography>
</Box>
<Box sx={{ display: "flex", justifyContent: "center" }}>{name}</Box>
</Box>
<Divider sx={{ marginTop: "8px" }} />
<Box