New Interface (initial commit)
This commit is contained in:
@@ -21,6 +21,7 @@ import { useCustomTheme } from "@/components/layout/use-custom-theme";
|
||||
import getSystem from "@/utils/get-system";
|
||||
import "dayjs/locale/ru";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import { getPortableFlag } from "@/services/cmds";
|
||||
import React from "react";
|
||||
import { useListen } from "@/hooks/use-listen";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -478,19 +479,7 @@ const Layout = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 5000,
|
||||
onError: (error, key) => {
|
||||
console.error(`[SWR Error] Key: ${key}, Error:`, error);
|
||||
if (key !== "getAutotemProxy") {
|
||||
console.error(`SWR Error for ${key}:`, error);
|
||||
}
|
||||
},
|
||||
dedupingInterval: 2000,
|
||||
}}
|
||||
>
|
||||
<SWRConfig value={{ errorRetryCount: 3 }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<NoticeManager />
|
||||
<div
|
||||
@@ -507,13 +496,22 @@ const Layout = () => {
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
|
||||
|
||||
{/* 1. Убрали класс "layout" с компонента Paper */}
|
||||
<Paper
|
||||
square
|
||||
elevation={0}
|
||||
className={`${OS} layout`}
|
||||
className={OS} // Был: className={`${OS} layout`}
|
||||
style={{
|
||||
borderTopLeftRadius: "0px",
|
||||
borderTopRightRadius: "0px",
|
||||
// Добавляем стили, чтобы контейнер занимал все пространство
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
display: "flex", // Используем flex, чтобы контент растянулся
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
@@ -532,63 +530,30 @@ const Layout = () => {
|
||||
? {
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--divider-color)",
|
||||
width: "100vw",
|
||||
height: "100vh",
|
||||
}
|
||||
: {},
|
||||
]}
|
||||
>
|
||||
<div className="layout__left">
|
||||
<div className="the-logo" data-tauri-drag-region="true">
|
||||
<div
|
||||
data-tauri-drag-region="true"
|
||||
style={{
|
||||
height: "27px",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<SvgIcon
|
||||
component={isDark ? iconDark : iconLight}
|
||||
style={{
|
||||
height: "36px",
|
||||
width: "36px",
|
||||
marginTop: "-3px",
|
||||
marginRight: "5px",
|
||||
marginLeft: "-3px",
|
||||
}}
|
||||
inheritViewBox
|
||||
/>
|
||||
<LogoSvg fill={isDark ? "white" : "black"} />
|
||||
</div>
|
||||
<UpdateButton className="the-newbtn" />
|
||||
</div>
|
||||
{/* 2. Левая колонка <div className="layout__left">...</div> ПОЛНОСТЬЮ УДАЛЕНА */}
|
||||
|
||||
<List className="the-menu">
|
||||
{routers.map((router) => (
|
||||
<LayoutItem
|
||||
key={router.label}
|
||||
to={router.path}
|
||||
icon={router.icon}
|
||||
>
|
||||
{t(router.label)}
|
||||
</LayoutItem>
|
||||
))}
|
||||
</List>
|
||||
{/* 3. Оставляем только "правую" часть, которая теперь станет основной */}
|
||||
{/* и заставляем ее занять все доступное место */}
|
||||
<div
|
||||
className="main-content-area"
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{/* 4. Бар-разделитель <div className="the-bar"></div> тоже удален, он больше не нужен */}
|
||||
|
||||
<div className="the-traffic">
|
||||
<LayoutTraffic />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="layout__right">
|
||||
<div className="the-bar"></div>
|
||||
|
||||
<div className="the-content">
|
||||
<div
|
||||
className="the-content"
|
||||
style={{ flex: 1, position: "relative" }}
|
||||
>
|
||||
{React.cloneElement(routersEles, { key: location.pathname })}
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
|
||||
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
|
||||
</ThemeProvider>
|
||||
</SWRConfig>
|
||||
);
|
||||
|
||||
@@ -4,8 +4,9 @@ import ProfilesPage from "./profiles";
|
||||
import SettingsPage from "./settings";
|
||||
import ConnectionsPage from "./connections";
|
||||
import RulesPage from "./rules";
|
||||
import HomePage from "./home";
|
||||
// import HomePage from "./home"; // Удаляем импорт старой HomePage
|
||||
import UnlockPage from "./unlock";
|
||||
import MinimalHomePage from "./home";
|
||||
import { BaseErrorBoundary } from "@/components/base";
|
||||
|
||||
import HomeSvg from "@/assets/image/itemicon/home.svg?react";
|
||||
@@ -25,17 +26,22 @@ import SubjectRoundedIcon from "@mui/icons-material/SubjectRounded";
|
||||
import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
|
||||
import HomeRoundedIcon from "@mui/icons-material/HomeRounded";
|
||||
import LockOpenRoundedIcon from "@mui/icons-material/LockOpenRounded";
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export const routers = [
|
||||
{
|
||||
label: "Label-Home",
|
||||
path: "/",
|
||||
element: <Navigate to="/home" replace />,
|
||||
},
|
||||
{
|
||||
label: "Label-Home", // Изменяем label для главной страницы (если нужно)
|
||||
path: "/home",
|
||||
icon: [<HomeRoundedIcon />, <HomeSvg />],
|
||||
element: <HomePage />,
|
||||
element: <MinimalHomePage />,
|
||||
},
|
||||
{
|
||||
label: "Label-Proxies",
|
||||
path: "/",
|
||||
path: "/proxies",
|
||||
icon: [<WifiRoundedIcon />, <ProxiesSvg />],
|
||||
element: <ProxiesPage />,
|
||||
},
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useMemo, useRef, useState, useCallback } from "react";
|
||||
import React, { useMemo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Box, Button, IconButton, MenuItem } from "@mui/material";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
TableChartRounded,
|
||||
TableRowsRounded,
|
||||
PlayCircleOutlineRounded,
|
||||
PauseCircleOutlineRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { closeAllConnections } from "@/services/api";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useConnectionSetting } from "@/services/states";
|
||||
import { BaseEmpty, BasePage } from "@/components/base";
|
||||
import { ConnectionItem } from "@/components/connection/connection-item";
|
||||
import { ConnectionTable } from "@/components/connection/connection-table";
|
||||
import {
|
||||
ConnectionDetail,
|
||||
ConnectionDetailRef,
|
||||
} from "@/components/connection/connection-detail";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { BaseSearchBox } from "@/components/base/base-search-box";
|
||||
import { BaseStyledSelect } from "@/components/base/base-styled-select";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { useVisibility } from "@/hooks/use-visibility";
|
||||
import { useAppData } from "@/providers/app-data-provider";
|
||||
import { closeAllConnections } from "@/services/api";
|
||||
import parseTraffic from "@/utils/parse-traffic";
|
||||
import { cn } from "@root/lib/utils";
|
||||
|
||||
// Компоненты
|
||||
import { BaseEmpty } from "@/components/base";
|
||||
import { ConnectionItem } from "@/components/connection/connection-item";
|
||||
import { ConnectionTable } from "@/components/connection/connection-table";
|
||||
import { ConnectionDetail, ConnectionDetailRef } from "@/components/connection/connection-detail";
|
||||
import { BaseSearchBox, type SearchState } from "@/components/base/base-search-box";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// Иконки
|
||||
import { List, Table2, PlayCircle, PauseCircle, ArrowDown, ArrowUp, Menu } from "lucide-react";
|
||||
|
||||
const initConn: IConnections = {
|
||||
uploadTotal: 0,
|
||||
@@ -35,85 +35,65 @@ type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
|
||||
|
||||
const ConnectionsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const pageVisible = useVisibility();
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
const [curOrderOpt, setOrderOpt] = useState("Default");
|
||||
|
||||
// 使用全局数据
|
||||
const { connections } = useAppData();
|
||||
|
||||
const [setting, setSetting] = useConnectionSetting();
|
||||
|
||||
const isTableLayout = setting.layout === "table";
|
||||
|
||||
const orderOpts: Record<string, OrderFunc> = {
|
||||
Default: (list) =>
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
new Date(b.start || "0").getTime()! -
|
||||
new Date(a.start || "0").getTime()!,
|
||||
),
|
||||
Default: (list) => list.sort((a, b) => new Date(b.start || "0").getTime()! - new Date(a.start || "0").getTime()!),
|
||||
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
|
||||
"Download Speed": (list) =>
|
||||
list.sort((a, b) => b.curDownload! - a.curDownload!),
|
||||
"Download Speed": (list) => list.sort((a, b) => b.curDownload! - a.curDownload!),
|
||||
};
|
||||
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [frozenData, setFrozenData] = useState<IConnections | null>(null);
|
||||
|
||||
// 使用全局连接数据
|
||||
const displayData = useMemo(() => {
|
||||
if (!pageVisible) return initConn;
|
||||
|
||||
if (isPaused) {
|
||||
return (
|
||||
frozenData ?? {
|
||||
uploadTotal: connections.uploadTotal,
|
||||
downloadTotal: connections.downloadTotal,
|
||||
connections: connections.data,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
uploadTotal: connections.uploadTotal,
|
||||
downloadTotal: connections.downloadTotal,
|
||||
connections: connections.data,
|
||||
};
|
||||
const currentData = { uploadTotal: connections.uploadTotal, downloadTotal: connections.downloadTotal, connections: connections.data };
|
||||
if (isPaused) return frozenData ?? currentData;
|
||||
return currentData;
|
||||
}, [isPaused, frozenData, connections, pageVisible]);
|
||||
|
||||
const [filterConn] = useMemo(() => {
|
||||
const filterConn = useMemo(() => {
|
||||
const orderFunc = orderOpts[curOrderOpt];
|
||||
let conns = displayData.connections.filter((conn) => {
|
||||
const { host, destinationIP, process } = conn.metadata;
|
||||
return (
|
||||
match(host || "") || match(destinationIP || "") || match(process || "")
|
||||
);
|
||||
return match(host || "") || match(destinationIP || "") || match(process || "");
|
||||
});
|
||||
|
||||
if (orderFunc) conns = orderFunc(conns);
|
||||
|
||||
return [conns];
|
||||
return conns;
|
||||
}, [displayData, match, curOrderOpt]);
|
||||
|
||||
const onCloseAll = useLockFn(closeAllConnections);
|
||||
const [scrollingElement, setScrollingElement] = useState<HTMLElement | Window | null>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const detailRef = useRef<ConnectionDetailRef>(null!);
|
||||
|
||||
const handleSearch = useCallback((match: (content: string) => boolean) => {
|
||||
setMatch(() => match);
|
||||
const scrollerRefCallback = useCallback((node: HTMLElement | Window | null) => {
|
||||
setScrollingElement(node);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollingElement) return;
|
||||
const handleScroll = () => {
|
||||
const scrollTop = scrollingElement instanceof Window ? scrollingElement.scrollY : scrollingElement.scrollTop;
|
||||
setIsScrolled(scrollTop > 5);
|
||||
};
|
||||
|
||||
scrollingElement.addEventListener('scroll', handleScroll);
|
||||
return () => scrollingElement.removeEventListener('scroll', handleScroll);
|
||||
}, [scrollingElement]);
|
||||
|
||||
const onCloseAll = useLockFn(closeAllConnections);
|
||||
const detailRef = useRef<ConnectionDetailRef>(null!);
|
||||
const handleSearch = useCallback((m: (content: string) => boolean) => setMatch(() => m), []);
|
||||
const handlePauseToggle = useCallback(() => {
|
||||
setIsPaused((prev) => {
|
||||
if (!prev) {
|
||||
setFrozenData({
|
||||
uploadTotal: connections.uploadTotal,
|
||||
downloadTotal: connections.downloadTotal,
|
||||
connections: connections.data,
|
||||
});
|
||||
setFrozenData({ uploadTotal: connections.uploadTotal, downloadTotal: connections.downloadTotal, connections: connections.data });
|
||||
} else {
|
||||
setFrozenData(null);
|
||||
}
|
||||
@@ -121,113 +101,77 @@ const ConnectionsPage = () => {
|
||||
});
|
||||
}, [connections]);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
title={<span style={{ whiteSpace: "nowrap" }}>{t("Connections")}</span>}
|
||||
contentStyle={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
header={
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Box sx={{ mx: 1 }}>
|
||||
{t("Downloaded")}: {parseTraffic(displayData.downloadTotal)}
|
||||
</Box>
|
||||
<Box sx={{ mx: 1 }}>
|
||||
{t("Uploaded")}: {parseTraffic(displayData.uploadTotal)}
|
||||
</Box>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
setSetting((o) =>
|
||||
o?.layout !== "table"
|
||||
? { ...o, layout: "table" }
|
||||
: { ...o, layout: "list" },
|
||||
)
|
||||
}
|
||||
>
|
||||
{isTableLayout ? (
|
||||
<TableRowsRounded titleAccess={t("List View")} />
|
||||
) : (
|
||||
<TableChartRounded titleAccess={t("Table View")} />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handlePauseToggle}
|
||||
title={isPaused ? t("Resume") : t("Pause")}
|
||||
>
|
||||
{isPaused ? (
|
||||
<PlayCircleOutlineRounded />
|
||||
) : (
|
||||
<PauseCircleOutlineRounded />
|
||||
)}
|
||||
</IconButton>
|
||||
<Button size="small" variant="contained" onClick={onCloseAll}>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{t("Close All")}</span>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
pt: 1,
|
||||
mb: 0.5,
|
||||
mx: "10px",
|
||||
height: "36px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
userSelect: "text",
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
{!isTableLayout && (
|
||||
<BaseStyledSelect
|
||||
value={curOrderOpt}
|
||||
onChange={(e) => setOrderOpt(e.target.value)}
|
||||
>
|
||||
{Object.keys(orderOpts).map((opt) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
<span style={{ fontSize: 14 }}>{t(opt)}</span>
|
||||
</MenuItem>
|
||||
))}
|
||||
</BaseStyledSelect>
|
||||
)}
|
||||
<BaseSearchBox onSearch={handleSearch} />
|
||||
</Box>
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Settings"), path: "/settings" },
|
||||
{ label: t("Logs"), path: "/logs" },
|
||||
{ label: t("Proxies"), path: "/proxies" },
|
||||
{ label: t("Rules"), path: "/rules" },
|
||||
];
|
||||
|
||||
{filterConn.length === 0 ? (
|
||||
<BaseEmpty />
|
||||
) : isTableLayout ? (
|
||||
<ConnectionTable
|
||||
connections={filterConn}
|
||||
onShowDetail={(detail) => detailRef.current?.open(detail)}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
style={{
|
||||
flex: 1,
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
data={filterConn}
|
||||
itemContent={(_, item) => (
|
||||
<ConnectionItem
|
||||
value={item}
|
||||
onShowDetail={() => detailRef.current?.open(item)}
|
||||
/>
|
||||
return (
|
||||
<div className="h-full w-full relative">
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
|
||||
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled }
|
||||
)}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t("Connections")}</h2>
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1"><ArrowDown className="h-4 w-4 text-green-500" />{parseTraffic(displayData.downloadTotal)}</div>
|
||||
<div className="flex items-center gap-1"><ArrowUp className="h-4 w-4 text-sky-500" />{parseTraffic(displayData.uploadTotal)}</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={() => setSetting((o) => (o?.layout !== "table" ? { ...o, layout: "table" } : { ...o, layout: "list" }))}>{isTableLayout ? <List className="h-5 w-5" /> : <Table2 className="h-5 w-5" />}</Button></TooltipTrigger><TooltipContent><p>{isTableLayout ? t("List View") : t("Table View")}</p></TooltipContent></Tooltip>
|
||||
<Tooltip><TooltipTrigger asChild><Button variant="ghost" size="icon" onClick={handlePauseToggle}>{isPaused ? <PlayCircle className="h-5 w-5" /> : <PauseCircle className="h-5 w-5" />}</Button></TooltipTrigger><TooltipContent><p>{isPaused ? t("Resume") : t("Pause")}</p></TooltipContent></Tooltip>
|
||||
<Button size="sm" variant="destructive" onClick={onCloseAll}>{t("Close All")}</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon" title={t("Menu")}><Menu className="h-5 w-5" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (<DropdownMenuItem key={item.path} onSelect={() => navigate(item.path)} disabled={location.pathname === item.path}>{item.label}</DropdownMenuItem>))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{!isTableLayout && (
|
||||
<Select value={curOrderOpt} onValueChange={(value) => setOrderOpt(value)}>
|
||||
<SelectTrigger className="w-[180px]"><SelectValue placeholder={t("Sort by")} /></SelectTrigger>
|
||||
<SelectContent>{Object.keys(orderOpts).map((opt) => (<SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>))}</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<ConnectionDetail ref={detailRef} />
|
||||
</BasePage>
|
||||
<div className="flex-1"><BaseSearchBox onSearch={handleSearch} /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0 pt-28">
|
||||
{filterConn.length === 0 ? (
|
||||
<BaseEmpty />
|
||||
) : isTableLayout ? (
|
||||
<div className="p-4 pt-0 h-full w-full">
|
||||
<ConnectionTable
|
||||
connections={filterConn}
|
||||
onShowDetail={(detail) => detailRef.current?.open(detail)}
|
||||
scrollerRef={scrollerRefCallback}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Virtuoso
|
||||
scrollerRef={scrollerRefCallback}
|
||||
data={filterConn}
|
||||
className="h-full w-full"
|
||||
itemContent={(_, item) => <ConnectionItem value={item} onShowDetail={() => detailRef.current?.open(item)} />}
|
||||
/>
|
||||
)}
|
||||
<ConnectionDetail ref={detailRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,397 +1,189 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React, { useRef, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useLockFn } from 'ahooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Импорты
|
||||
import { useProfiles } from '@/hooks/use-profiles';
|
||||
import { ProfileViewer, ProfileViewerRef } from '@/components/profile/profile-viewer';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
IconButton,
|
||||
useTheme,
|
||||
keyframes,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormGroup,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Grid,
|
||||
} from "@mui/material";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { useProfiles } from "@/hooks/use-profiles";
|
||||
import {
|
||||
RouterOutlined,
|
||||
SettingsOutlined,
|
||||
DnsOutlined,
|
||||
SpeedOutlined,
|
||||
HelpOutlineRounded,
|
||||
HistoryEduOutlined,
|
||||
} from "@mui/icons-material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ProxyTunCard } from "@/components/home/proxy-tun-card";
|
||||
import { ClashModeCard } from "@/components/home/clash-mode-card";
|
||||
import { EnhancedTrafficStats } from "@/components/home/enhanced-traffic-stats";
|
||||
import { useState } from "react";
|
||||
import { HomeProfileCard } from "@/components/home/home-profile-card";
|
||||
import { EnhancedCard } from "@/components/home/enhanced-card";
|
||||
import { CurrentProxyCard } from "@/components/home/current-proxy-card";
|
||||
import { BasePage } from "@/components/base";
|
||||
import { ClashInfoCard } from "@/components/home/clash-info-card";
|
||||
import { SystemInfoCard } from "@/components/home/system-info-card";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { entry_lightweight_mode, openWebUrl } from "@/services/cmds";
|
||||
import { TestCard } from "@/components/home/test-card";
|
||||
import { IpInfoCard } from "@/components/home/ip-info-card";
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
|
||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronsUpDown, Check, PlusCircle, Menu, Wrench, AlertTriangle } from 'lucide-react';
|
||||
import { useVerge } from '@/hooks/use-verge';
|
||||
import { useSystemState } from '@/hooks/use-system-state';
|
||||
import { useServiceInstaller } from '@/hooks/useServiceInstaller';
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ProxySelectors } from '@/components/home/proxy-selectors';
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { closeAllConnections } from '@/services/api';
|
||||
|
||||
// 定义旋转动画
|
||||
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";
|
||||
}
|
||||
|
||||
// 定义首页卡片设置接口
|
||||
interface HomeCardsSettings {
|
||||
profile: boolean;
|
||||
proxy: boolean;
|
||||
network: boolean;
|
||||
mode: boolean;
|
||||
traffic: boolean;
|
||||
info: boolean;
|
||||
clashinfo: boolean;
|
||||
systeminfo: boolean;
|
||||
test: boolean;
|
||||
ip: boolean;
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
// 首页设置对话框组件接口
|
||||
interface HomeSettingsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
homeCards: HomeCardsSettings;
|
||||
onSave: (cards: HomeCardsSettings) => void;
|
||||
}
|
||||
|
||||
// 首页设置对话框组件
|
||||
const HomeSettingsDialog = ({
|
||||
open,
|
||||
onClose,
|
||||
homeCards,
|
||||
onSave,
|
||||
}: HomeSettingsDialogProps) => {
|
||||
const MinimalHomePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [cards, setCards] = useState<HomeCardsSettings>(homeCards);
|
||||
const { patchVerge } = useVerge();
|
||||
|
||||
const handleToggle = (key: string) => {
|
||||
setCards((prev: HomeCardsSettings) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
await patchVerge({ home_cards: cards });
|
||||
onSave(cards);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{t("Home Settings")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.profile || false}
|
||||
onChange={() => handleToggle("profile")}
|
||||
/>
|
||||
}
|
||||
label={t("Profile Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.proxy || false}
|
||||
onChange={() => handleToggle("proxy")}
|
||||
/>
|
||||
}
|
||||
label={t("Current Proxy Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.network || false}
|
||||
onChange={() => handleToggle("network")}
|
||||
/>
|
||||
}
|
||||
label={t("Network Settings Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.mode || false}
|
||||
onChange={() => handleToggle("mode")}
|
||||
/>
|
||||
}
|
||||
label={t("Proxy Mode Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.traffic || false}
|
||||
onChange={() => handleToggle("traffic")}
|
||||
/>
|
||||
}
|
||||
label={t("Traffic Stats Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.test || false}
|
||||
onChange={() => handleToggle("test")}
|
||||
/>
|
||||
}
|
||||
label={t("Website Tests Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.ip || false}
|
||||
onChange={() => handleToggle("ip")}
|
||||
/>
|
||||
}
|
||||
label={t("IP Information Card")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.clashinfo || false}
|
||||
onChange={() => handleToggle("clashinfo")}
|
||||
/>
|
||||
}
|
||||
label={t("Clash Info Cards")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={cards.systeminfo || false}
|
||||
onChange={() => handleToggle("systeminfo")}
|
||||
/>
|
||||
}
|
||||
label={t("System Info Cards")}
|
||||
/>
|
||||
</FormGroup>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>{t("Cancel")}</Button>
|
||||
<Button onClick={handleSave} color="primary">
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const HomePage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { verge } = useVerge();
|
||||
const { current, mutateProfiles } = useProfiles();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
|
||||
// 设置弹窗的状态
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
// 卡片显示状态
|
||||
const [homeCards, setHomeCards] = useState<HomeCardsSettings>(
|
||||
(verge?.home_cards as HomeCardsSettings) || {
|
||||
profile: true,
|
||||
proxy: true,
|
||||
network: true,
|
||||
mode: true,
|
||||
traffic: true,
|
||||
clashinfo: true,
|
||||
systeminfo: true,
|
||||
test: true,
|
||||
ip: true,
|
||||
},
|
||||
);
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЙ 1: Правильно используем хук ---
|
||||
const { profiles, patchProfiles, activateSelected, mutateProfiles } = useProfiles();
|
||||
const viewerRef = useRef<ProfileViewerRef>(null);
|
||||
|
||||
// 导航到订阅页面
|
||||
const goToProfiles = () => {
|
||||
navigate("/profile");
|
||||
};
|
||||
// Воссоздаем логику фильтрации профилей здесь
|
||||
const profileItems = useMemo(() => {
|
||||
const items = profiles && Array.isArray(profiles.items) ? profiles.items : [];
|
||||
const allowedTypes = ["local", "remote"];
|
||||
// Добавляем явное указание типа, чтобы избежать ошибок
|
||||
return items.filter((i: any) => i && allowedTypes.includes(i.type!));
|
||||
}, [profiles]);
|
||||
|
||||
// 导航到代理页面
|
||||
const goToProxies = () => {
|
||||
navigate("/");
|
||||
};
|
||||
const currentProfileName = useMemo(() => {
|
||||
// Находим в списке профилей тот, чей uid совпадает с активным
|
||||
return profileItems.find(p => p.uid === profiles?.current)?.name || profiles?.current;
|
||||
}, [profileItems, profiles?.current]);
|
||||
// Воссоздаем логику активации профиля здесь
|
||||
const activateProfile = useCallback(async (uid: string, notifySuccess: boolean) => {
|
||||
try {
|
||||
await patchProfiles({ current: uid });
|
||||
await closeAllConnections();
|
||||
await activateSelected();
|
||||
if (notifySuccess) {
|
||||
toast.success(t("Profile Switched"));
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || err.toString());
|
||||
mutateProfiles(); // Откатываем в случае ошибки
|
||||
}
|
||||
}, [patchProfiles, activateSelected, mutateProfiles, t]);
|
||||
|
||||
// 导航到设置页面
|
||||
const goToSettings = () => {
|
||||
navigate("/settings");
|
||||
};
|
||||
const handleProfileChange = useLockFn(async (uid: string) => {
|
||||
if (profiles?.current === uid) return;
|
||||
await activateProfile(uid, true);
|
||||
});
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЙ 1 ---
|
||||
|
||||
// 文档链接函数
|
||||
const toGithubDoc = useLockFn(() => {
|
||||
return openWebUrl("https://clash-verge-rev.github.io/index.html");
|
||||
const { verge, patchVerge, mutateVerge } = useVerge();
|
||||
const { isAdminMode, isServiceMode } = useSystemState();
|
||||
const { installServiceAndRestartCore } = useServiceInstaller();
|
||||
const isTunAvailable = isServiceMode || isAdminMode;
|
||||
const isProxyEnabled = verge?.enable_system_proxy || verge?.enable_tun_mode;
|
||||
|
||||
const handleToggleProxy = useLockFn(async () => {
|
||||
const turningOn = !isProxyEnabled;
|
||||
try {
|
||||
if (turningOn) {
|
||||
await patchVerge({ enable_tun_mode: true, enable_system_proxy: false });
|
||||
toast.success(t('Proxy enabled'));
|
||||
} else {
|
||||
await patchVerge({ enable_tun_mode: false, enable_system_proxy: false });
|
||||
toast.success(t('Proxy disabled'));
|
||||
}
|
||||
mutateVerge();
|
||||
} catch (error: any) {
|
||||
toast.error(t('Failed to toggle proxy'), { description: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 新增:打开设置弹窗
|
||||
const openSettings = () => {
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
// 新增:保存设置时用requestIdleCallback/setTimeout
|
||||
const handleSaveSettings = (newCards: HomeCardsSettings) => {
|
||||
if (window.requestIdleCallback) {
|
||||
window.requestIdleCallback(() => setHomeCards(newCards));
|
||||
} else {
|
||||
setTimeout(() => setHomeCards(newCards), 0);
|
||||
}
|
||||
};
|
||||
const navMenuItems = [
|
||||
{ label: 'Profiles', path: '/profile' },
|
||||
{ label: 'Settings', path: '/settings' },
|
||||
{ label: 'Logs', path: '/logs' },
|
||||
{ label: 'Proxies', path: '/proxies' },
|
||||
{ label: 'Connections', path: '/connections' },
|
||||
{ label: 'Rules', path: '/rules' },
|
||||
];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title={t("Label-Home")}
|
||||
contentStyle={{ padding: 2 }}
|
||||
header={
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Tooltip title={t("LightWeight Mode")} arrow>
|
||||
<IconButton
|
||||
onClick={async () => await entry_lightweight_mode()}
|
||||
size="small"
|
||||
color="inherit"
|
||||
>
|
||||
<HistoryEduOutlined />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("Manual")} arrow>
|
||||
<IconButton onClick={toGithubDoc} size="small" color="inherit">
|
||||
<HelpOutlineRounded />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("Home Settings")} arrow>
|
||||
<IconButton onClick={openSettings} size="small" color="inherit">
|
||||
<SettingsOutlined />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={1.5} columns={{ xs: 6, sm: 6, md: 12 }}>
|
||||
{/* 订阅和当前节点部分 */}
|
||||
{homeCards.profile && (
|
||||
<Grid size={6}>
|
||||
<HomeProfileCard
|
||||
current={current}
|
||||
onProfileUpdated={mutateProfiles}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
<div className="flex flex-col h-screen p-5">
|
||||
<header className="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-xs pt-5 z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<span className="truncate">{currentProfileName}</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
|
||||
<DropdownMenuLabel>{t("Profiles")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{profileItems.map((p) => (
|
||||
<DropdownMenuItem key={p.uid} onSelect={() => handleProfileChange(p.uid)}>
|
||||
<span className="flex-1 truncate">{p.name}</span>
|
||||
{profiles?.current === p.uid && <Check className="ml-4 h-4 w-4" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => viewerRef.current?.create()}>
|
||||
<PlusCircle className="mr-2 h-4 w-4" />
|
||||
<span>{t("Add New Profile")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
{homeCards.proxy && (
|
||||
<Grid size={6}>
|
||||
<CurrentProxyCard />
|
||||
</Grid>
|
||||
)}
|
||||
<div className="absolute top-5 right-5 z-20">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{navMenuItems.map((item) => (
|
||||
<DropdownMenuItem key={item.path} onSelect={() => navigate(item.path)}>
|
||||
{t(item.label)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* 代理和网络设置区域 */}
|
||||
{homeCards.network && (
|
||||
<Grid size={6}>
|
||||
<NetworkSettingsCard />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{homeCards.mode && (
|
||||
<Grid size={6}>
|
||||
<ClashModeEnhancedCard />
|
||||
</Grid>
|
||||
)}
|
||||
<div className="flex flex-col items-center justify-center flex-grow text-center w-full">
|
||||
<h1 className="text-4xl mb-8 font-semibold" style={{ color: isProxyEnabled ? '#22c55e' : '#ef4444' }}>
|
||||
{isProxyEnabled ? t('Connected') : t('Disconnected')}
|
||||
</h1>
|
||||
|
||||
{/* 增强的流量统计区域 */}
|
||||
{homeCards.traffic && (
|
||||
<Grid size={12}>
|
||||
<EnhancedCard
|
||||
title={t("Traffic Stats")}
|
||||
icon={<SpeedOutlined />}
|
||||
iconColor="secondary"
|
||||
>
|
||||
<EnhancedTrafficStats />
|
||||
</EnhancedCard>
|
||||
</Grid>
|
||||
)}
|
||||
{/* 测试网站部分 */}
|
||||
{homeCards.test && (
|
||||
<Grid size={6}>
|
||||
<TestCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* IP信息卡片 */}
|
||||
{homeCards.ip && (
|
||||
<Grid size={6}>
|
||||
<IpInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* Clash信息 */}
|
||||
{homeCards.clashinfo && (
|
||||
<Grid size={6}>
|
||||
<ClashInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
{/* 系统信息 */}
|
||||
{homeCards.systeminfo && (
|
||||
<Grid size={6}>
|
||||
<SystemInfoCard />
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<div className="scale-[3.5] my-16">
|
||||
<Switch
|
||||
disabled={!isTunAvailable}
|
||||
checked={!!isProxyEnabled}
|
||||
onCheckedChange={handleToggleProxy}
|
||||
aria-label={t("Toggle Proxy")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 首页设置弹窗 */}
|
||||
<HomeSettingsDialog
|
||||
open={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
homeCards={homeCards}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</BasePage>
|
||||
<div className="w-full max-w-sm transition-all">
|
||||
{!isTunAvailable && (
|
||||
<Alert variant="destructive" className="text-center flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<AlertTitle>{t("Attention Required")}</AlertTitle>
|
||||
</div>
|
||||
<AlertDescription>
|
||||
{t("TUN requires Service Mode or Admin Mode")}
|
||||
</AlertDescription>
|
||||
{!isServiceMode && !isAdminMode && (
|
||||
<Button size="sm" className="mt-2" onClick={installServiceAndRestartCore}>
|
||||
<Wrench className="mr-2 h-4 w-4" />
|
||||
{t("Install Service")}
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full mt-8">
|
||||
<ProxySelectors />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 增强版网络设置卡片组件
|
||||
const NetworkSettingsCard = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Network Settings")}
|
||||
icon={<DnsOutlined />}
|
||||
iconColor="primary"
|
||||
action={null}
|
||||
>
|
||||
<ProxyTunCard />
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
||||
|
||||
// 增强版 Clash 模式卡片组件
|
||||
const ClashModeEnhancedCard = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("Proxy Mode")}
|
||||
icon={<RouterOutlined />}
|
||||
iconColor="info"
|
||||
action={null}
|
||||
>
|
||||
<ClashModeCard />
|
||||
</EnhancedCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
export default MinimalHomePage;
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Box, Button, IconButton, MenuItem } from "@mui/material";
|
||||
// LogPage.tsx
|
||||
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "foxact/use-local-storage";
|
||||
|
||||
import {
|
||||
PlayCircleOutlineRounded,
|
||||
PauseCircleOutlineRounded,
|
||||
} from "@mui/icons-material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Play, Pause, Trash2, Menu } from "lucide-react";
|
||||
import { LogLevel } from "@/hooks/use-log-data";
|
||||
import { useClashInfo } from "@/hooks/use-clash";
|
||||
import { useEnableLog } from "@/services/states";
|
||||
import { BaseEmpty, BasePage } from "@/components/base";
|
||||
import { BaseEmpty } from "@/components/base/base-empty";
|
||||
import LogItem from "@/components/log/log-item";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import { BaseSearchBox } from "@/components/base/base-search-box";
|
||||
import { BaseStyledSelect } from "@/components/base/base-styled-select";
|
||||
import { SearchState } from "@/components/base/base-search-box";
|
||||
import {
|
||||
useGlobalLogData,
|
||||
@@ -23,13 +25,29 @@ import {
|
||||
changeLogLevel,
|
||||
toggleLogEnabled,
|
||||
} from "@/services/global-log-service";
|
||||
import { cn } from "@root/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const LogPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [enableLog, setEnableLog] = useEnableLog();
|
||||
const { clashInfo } = useClashInfo();
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
const [logLevel, setLogLevel] = useLocalStorage<LogLevel>(
|
||||
"log:log-level",
|
||||
"info",
|
||||
@@ -38,13 +56,23 @@ const LogPage = () => {
|
||||
const logData = useGlobalLogData(logLevel);
|
||||
const [searchState, setSearchState] = useState<SearchState>();
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const handleScroll = () => {
|
||||
if (scrollContainer) setIsScrolled(scrollContainer.scrollTop > 5);
|
||||
};
|
||||
scrollContainer?.addEventListener("scroll", handleScroll);
|
||||
return () => scrollContainer?.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const filterLogs = useMemo(() => {
|
||||
return logData
|
||||
? logData.filter((data) => {
|
||||
// 构建完整的搜索文本,包含时间、类型和内容
|
||||
const searchText =
|
||||
`${data.time || ""} ${data.type} ${data.payload}`.toLowerCase();
|
||||
|
||||
return logLevel === "all"
|
||||
? match(searchText)
|
||||
: data.type.toLowerCase() === logLevel && match(searchText);
|
||||
@@ -68,89 +96,116 @@ const LogPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(matcher: (content: string) => boolean, state: SearchState) => {
|
||||
setMatch(() => matcher);
|
||||
setSearchState(state);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Settings"), path: "/settings" },
|
||||
{ label: t("Proxies"), path: "/proxies" },
|
||||
{ label: t("Connections"), path: "/connections" },
|
||||
{ label: t("Rules"), path: "/rules" },
|
||||
];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
title={t("Logs")}
|
||||
contentStyle={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
}}
|
||||
header={
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<IconButton
|
||||
title={t(enableLog ? "Pause" : "Resume")}
|
||||
size="small"
|
||||
color="inherit"
|
||||
onClick={handleToggleLog}
|
||||
>
|
||||
{enableLog ? (
|
||||
<PauseCircleOutlineRounded />
|
||||
) : (
|
||||
<PlayCircleOutlineRounded />
|
||||
)}
|
||||
</IconButton>
|
||||
|
||||
{enableLog === true && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
clearGlobalLogs();
|
||||
}}
|
||||
>
|
||||
{t("Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
pt: 1,
|
||||
mb: 0.5,
|
||||
mx: "10px",
|
||||
height: "39px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
<div className="h-full w-full relative">
|
||||
{/* "Липкая" шапка */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
|
||||
// Вместо блюра делаем солидный фон с тенью при прокрутке
|
||||
{ "bg-background shadow-md": isScrolled },
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
|
||||
)}
|
||||
>
|
||||
<BaseStyledSelect
|
||||
value={logLevel}
|
||||
onChange={(e) => handleLogLevelChange(e.target.value as LogLevel)}
|
||||
>
|
||||
<MenuItem value="all">ALL</MenuItem>
|
||||
<MenuItem value="info">INFO</MenuItem>
|
||||
<MenuItem value="warning">WARNING</MenuItem>
|
||||
<MenuItem value="error">ERROR</MenuItem>
|
||||
<MenuItem value="debug">DEBUG</MenuItem>
|
||||
</BaseStyledSelect>
|
||||
<BaseSearchBox
|
||||
onSearch={(matcher, state) => {
|
||||
setMatch(() => matcher);
|
||||
setSearchState(state);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t("Logs")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t(enableLog ? "Pause" : "Resume")}
|
||||
onClick={handleToggleLog}
|
||||
>
|
||||
{enableLog ? (
|
||||
<Pause className="h-5 w-5" />
|
||||
) : (
|
||||
<Play className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
{enableLog && (
|
||||
<Button size="sm" variant="outline" onClick={clearGlobalLogs}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("Clear")}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("Menu")}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
onSelect={() => navigate(item.path)}
|
||||
disabled={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={logLevel} onValueChange={handleLogLevelChange}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t("Log Level")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">ALL</SelectItem>
|
||||
<SelectItem value="info">INFO</SelectItem>
|
||||
<SelectItem value="warning">WARNING</SelectItem>
|
||||
<SelectItem value="error">ERROR</SelectItem>
|
||||
<SelectItem value="debug">DEBUG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-grow">
|
||||
<BaseSearchBox onSearch={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterLogs.length > 0 ? (
|
||||
<Virtuoso
|
||||
initialTopMostItemIndex={999}
|
||||
data={filterLogs}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
itemContent={(index, item) => (
|
||||
<LogItem value={item} searchState={searchState} />
|
||||
)}
|
||||
followOutput={"smooth"}
|
||||
/>
|
||||
) : (
|
||||
<BaseEmpty />
|
||||
)}
|
||||
</BasePage>
|
||||
{/* Возвращаем Virtuoso на место */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="absolute top-0 left-0 right-0 bottom-0 pt-32 overflow-y-auto"
|
||||
>
|
||||
{filterLogs.length > 0 ? (
|
||||
<Virtuoso
|
||||
data={filterLogs}
|
||||
itemContent={(index, item) => (
|
||||
<LogItem value={item} searchState={searchState} />
|
||||
)}
|
||||
followOutput={"smooth"}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<BaseEmpty />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,29 @@
|
||||
// ProxyPage.tsx
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useEffect } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Box, Button, ButtonGroup } from "@mui/material";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { closeAllConnections, getClashConfig } from "@/services/api";
|
||||
import { patchClashMode } from "@/services/cmds";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { BasePage } from "@/components/base";
|
||||
import { ProxyGroups } from "@/components/proxy/proxy-groups";
|
||||
import { ProviderButton } from "@/components/proxy/provider-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
const ProxyPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: clashConfig, mutate: mutateClash } = useSWR(
|
||||
"getClashConfig",
|
||||
@@ -19,19 +31,14 @@ const ProxyPage = () => {
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: true,
|
||||
dedupingInterval: 1000,
|
||||
errorRetryInterval: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
const { verge } = useVerge();
|
||||
|
||||
const modeList = ["rule", "global", "direct"];
|
||||
|
||||
const curMode = clashConfig?.mode?.toLowerCase();
|
||||
|
||||
const onChangeMode = useLockFn(async (mode: string) => {
|
||||
// 断开连接
|
||||
if (mode !== curMode && verge?.auto_close_connection) {
|
||||
closeAllConnections();
|
||||
}
|
||||
@@ -45,32 +52,66 @@ const ProxyPage = () => {
|
||||
}
|
||||
}, [curMode]);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
contentStyle={{ height: "101.5%" }}
|
||||
title={t("Proxy Groups")}
|
||||
header={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<ProviderButton />
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Settings"), path: "/settings" },
|
||||
{ label: t("Logs"), path: "/logs" },
|
||||
{ label: t("Connections"), path: "/connections" },
|
||||
{ label: t("Rules"), path: "/rules" },
|
||||
];
|
||||
|
||||
<ButtonGroup size="small">
|
||||
return (
|
||||
// Используем наш знакомый паттерн для создания макета с прокручиваемым контентом
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Шапка страницы */}
|
||||
<div className="p-4 pb-2 flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("Proxies")}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<ProviderButton />
|
||||
<div className="flex items-center rounded-md border bg-muted p-0.5">
|
||||
{modeList.map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={mode === curMode ? "contained" : "outlined"}
|
||||
variant={mode === curMode ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => onChangeMode(mode)}
|
||||
sx={{ textTransform: "capitalize" }}
|
||||
className="capitalize px-3 py-1 h-auto"
|
||||
>
|
||||
{t(mode)}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ProxyGroups mode={curMode!} />
|
||||
</BasePage>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("Menu")}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
onSelect={() => navigate(item.path)}
|
||||
disabled={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент, который будет скроллиться */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<ProxyGroups mode={curMode!} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,100 +1,158 @@
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { useState, useMemo, useRef, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
|
||||
import { Box } from "@mui/material";
|
||||
import { BaseEmpty, BasePage } from "@/components/base";
|
||||
import RuleItem from "@/components/rule/rule-item";
|
||||
import { ProviderButton } from "@/components/rule/provider-button";
|
||||
import { BaseSearchBox } from "@/components/base/base-search-box";
|
||||
import { ScrollTopButton } from "@/components/layout/scroll-top-button";
|
||||
import { useAppData } from "@/providers/app-data-provider";
|
||||
import { useVisibility } from "@/hooks/use-visibility";
|
||||
import { cn } from "@root/lib/utils";
|
||||
|
||||
// Компоненты
|
||||
import { BaseEmpty } from "@/components/base";
|
||||
import RuleItem from "@/components/rule/rule-item";
|
||||
import { ProviderButton } from "@/components/rule/provider-button";
|
||||
import { BaseSearchBox, SearchState } from "@/components/base/base-search-box";
|
||||
import { ScrollTopButton } from "@/components/layout/scroll-top-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// Иконки
|
||||
import { Menu } from "lucide-react";
|
||||
|
||||
const RulesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { rules = [], refreshRules, refreshRuleProviders } = useAppData();
|
||||
const [match, setMatch] = useState(() => (_: string) => true);
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const pageVisible = useVisibility();
|
||||
|
||||
// 在组件挂载时和页面获得焦点时刷新规则数据
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЙ 1 ---
|
||||
// Разделяем логику на два безопасных useEffect
|
||||
useEffect(() => {
|
||||
// Этот эффект сработает только один раз при монтировании компонента
|
||||
refreshRules();
|
||||
refreshRuleProviders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // Пустой массив зависимостей = запуск только один раз
|
||||
|
||||
useEffect(() => {
|
||||
// Этот эффект будет срабатывать только при изменении видимости страницы
|
||||
if (pageVisible) {
|
||||
refreshRules();
|
||||
refreshRuleProviders();
|
||||
}
|
||||
}, [refreshRules, refreshRuleProviders, pageVisible]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageVisible]);
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЙ 1 ---
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
return rules.filter((item) => match(item.payload));
|
||||
}, [rules, match]);
|
||||
|
||||
const scrollToTop = () => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
const currentScroller = scrollerRef.current;
|
||||
if (!currentScroller) return;
|
||||
const handleScroll = () => {
|
||||
const scrollTop = currentScroller.scrollTop;
|
||||
setIsScrolled(scrollTop > 5);
|
||||
setShowScrollTop(scrollTop > 100);
|
||||
};
|
||||
currentScroller.addEventListener("scroll", handleScroll);
|
||||
return () => currentScroller.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const handleScroll = (e: any) => {
|
||||
setShowScrollTop(e.target.scrollTop > 100);
|
||||
};
|
||||
const scrollToTop = useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЙ 2 ---
|
||||
// Оборачиваем обработчик поиска в useCallback для стабильности
|
||||
const handleSearch = useCallback((matcher: (content: string) => boolean) => {
|
||||
setMatch(() => matcher);
|
||||
}, []);
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЙ 2 ---
|
||||
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Settings"), path: "/settings" },
|
||||
{ label: t("Logs"), path: "/logs" },
|
||||
{ label: t("Proxies"), path: "/proxies" },
|
||||
{ label: t("Connections"), path: "/connections" },
|
||||
];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
title={t("Rules")}
|
||||
contentStyle={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "auto",
|
||||
}}
|
||||
header={
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<ProviderButton />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
pt: 1,
|
||||
mb: 0.5,
|
||||
mx: "10px",
|
||||
height: "36px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
}}
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
|
||||
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
|
||||
)}
|
||||
>
|
||||
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
|
||||
</Box>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("Rules")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-64">
|
||||
{/* Передаем стабильную функцию handleSearch в пропс */}
|
||||
<BaseSearchBox onSearch={handleSearch} />
|
||||
</div>
|
||||
<ProviderButton />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("Menu")}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
onSelect={() => navigate(item.path)}
|
||||
disabled={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredRules.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className="absolute top-0 left-0 right-0 bottom-0 pt-20 overflow-y-auto"
|
||||
>
|
||||
{filteredRules.length > 0 ? (
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
data={filteredRules}
|
||||
style={{
|
||||
flex: 1,
|
||||
}}
|
||||
className="h-full w-full"
|
||||
itemContent={(index, item) => (
|
||||
<RuleItem index={index + 1} value={item} />
|
||||
)}
|
||||
followOutput={"smooth"}
|
||||
scrollerRef={(ref) => {
|
||||
if (ref) ref.addEventListener("scroll", handleScroll);
|
||||
}}
|
||||
/>
|
||||
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
|
||||
</>
|
||||
) : (
|
||||
<BaseEmpty />
|
||||
)}
|
||||
</BasePage>
|
||||
) : (
|
||||
<BaseEmpty />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,112 +1,172 @@
|
||||
import { Box, ButtonGroup, IconButton, Grid } from "@mui/material";
|
||||
// SettingPage.tsx
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BasePage } from "@/components/base";
|
||||
import { GitHub, HelpOutlineRounded, Telegram } from "@mui/icons-material";
|
||||
import { openWebUrl } from "@/services/cmds";
|
||||
import SettingVergeBasic from "@/components/setting/setting-verge-basic";
|
||||
import SettingVergeAdvanced from "@/components/setting/setting-verge-advanced";
|
||||
import SettingClash from "@/components/setting/setting-clash";
|
||||
import SettingSystem from "@/components/setting/setting-system";
|
||||
import { useThemeMode } from "@/services/states";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
|
||||
// Убираем CardHeader и CardTitle из импортов
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Menu, Github, HelpCircle, Send } from "lucide-react";
|
||||
import { cn } from "@root/lib/utils";
|
||||
|
||||
const SettingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onError = (err: any) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const handleScroll = () => {
|
||||
if (scrollContainer) {
|
||||
setIsScrolled(scrollContainer.scrollTop > 10);
|
||||
}
|
||||
};
|
||||
scrollContainer?.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
scrollContainer?.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onError = (err: any) =>
|
||||
showNotice("error", err?.message || err.toString());
|
||||
};
|
||||
|
||||
const toGithubRepo = useLockFn(() => {
|
||||
return openWebUrl("https://github.com/clash-verge-rev/clash-verge-rev");
|
||||
});
|
||||
|
||||
const toGithubDoc = useLockFn(() => {
|
||||
return openWebUrl("https://clash-verge-rev.github.io/index.html");
|
||||
});
|
||||
|
||||
const toTelegramChannel = useLockFn(() => {
|
||||
return openWebUrl("https://t.me/clash_verge_re");
|
||||
});
|
||||
|
||||
const mode = useThemeMode();
|
||||
const isDark = mode === "light" ? false : true;
|
||||
const toGithubRepo = useLockFn(() =>
|
||||
openWebUrl("https://github.com/clash-verge-rev/clash-verge-rev"),
|
||||
);
|
||||
const toGithubDoc = useLockFn(() =>
|
||||
openWebUrl("https://clash-verge-rev.github.io/index.html"),
|
||||
);
|
||||
const toTelegramChannel = useLockFn(() =>
|
||||
openWebUrl("https://t.me/clash_verge_re"),
|
||||
);
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Logs"), path: "/logs" },
|
||||
{ label: t("Proxies"), path: "/proxies" },
|
||||
{ label: t("Connections"), path: "/connections" },
|
||||
{ label: t("Rules"), path: "/rules" },
|
||||
];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
title={t("Settings")}
|
||||
header={
|
||||
<ButtonGroup variant="contained" aria-label="Basic button group">
|
||||
<IconButton
|
||||
size="medium"
|
||||
color="inherit"
|
||||
<div className="h-full w-full relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 right-0 z-10 p-4 flex justify-between items-center transition-colors duration-200",
|
||||
{ "bg-background/80 backdrop-blur-sm": isScrolled },
|
||||
)}
|
||||
>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t("Settings")}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* ...кнопки... */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Manual")}
|
||||
onClick={toGithubDoc}
|
||||
>
|
||||
<HelpOutlineRounded fontSize="inherit" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="medium"
|
||||
color="inherit"
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("TG Channel")}
|
||||
onClick={toTelegramChannel}
|
||||
>
|
||||
<Telegram fontSize="inherit" />
|
||||
</IconButton>
|
||||
|
||||
<IconButton
|
||||
size="medium"
|
||||
color="inherit"
|
||||
<Send className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={t("Github Repo")}
|
||||
onClick={toGithubRepo}
|
||||
>
|
||||
<GitHub fontSize="inherit" />
|
||||
</IconButton>
|
||||
</ButtonGroup>
|
||||
}
|
||||
>
|
||||
<Grid container spacing={1.5} columns={{ xs: 6, sm: 6, md: 12 }}>
|
||||
<Grid size={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
marginBottom: 1.5,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<SettingSystem onError={onError} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<SettingClash onError={onError} />
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
marginBottom: 1.5,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<SettingVergeBasic onError={onError} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
<SettingVergeAdvanced onError={onError} />
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</BasePage>
|
||||
<Github className="h-5 w-5" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("Menu")}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.path}
|
||||
onSelect={() => navigate(item.path)}
|
||||
disabled={location.pathname === item.path}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="absolute top-0 left-0 right-0 bottom-0 pt-20 overflow-y-auto"
|
||||
>
|
||||
<div className="p-4 pt-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
|
||||
|
||||
{/* Column 1 */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SettingSystem onError={onError} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SettingClash onError={onError} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Column 2 */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SettingVergeBasic onError={onError} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<SettingVergeAdvanced onError={onError} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
import { Box, Button, Grid } from "@mui/material";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -13,17 +16,27 @@ import {
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
arrayMove,
|
||||
} from "@dnd-kit/sortable";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BasePage } from "@/components/base";
|
||||
// Новые импорты
|
||||
import { useVerge } from "@/hooks/use-verge";
|
||||
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 { ScrollTopButton } from "@/components/layout/scroll-top-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Menu, Play, Plus } from "lucide-react";
|
||||
import { cn } from "@root/lib/utils";
|
||||
|
||||
// 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";
|
||||
@@ -31,6 +44,7 @@ import youtube from "@/assets/image/test/youtube.svg?raw";
|
||||
|
||||
const TestPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
@@ -39,45 +53,43 @@ const TestPage = () => {
|
||||
);
|
||||
const { verge, mutateVerge, patchVerge } = useVerge();
|
||||
|
||||
// test list
|
||||
// Логика для "липкой" шапки и скролла
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const currentScroller = scrollerRef.current;
|
||||
if (!currentScroller) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const scrollTop = currentScroller.scrollTop;
|
||||
setIsScrolled(scrollTop > 5);
|
||||
setShowScrollTop(scrollTop > 100);
|
||||
};
|
||||
|
||||
currentScroller.addEventListener("scroll", handleScroll);
|
||||
return () => currentScroller.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = useCallback(() => {
|
||||
scrollerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Список тестов
|
||||
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,
|
||||
},
|
||||
{ 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>,
|
||||
) => {
|
||||
const onTestListItemChange = (uid: string, patch?: Partial<IVergeTestItem>) => {
|
||||
if (patch) {
|
||||
const newList = testList.map((x) => {
|
||||
if (x.uid === uid) {
|
||||
return { ...x, ...patch };
|
||||
}
|
||||
return x;
|
||||
});
|
||||
const newList = testList.map((x) => (x.uid === uid ? { ...x, ...patch } : x));
|
||||
mutateVerge({ ...verge, test_list: newList }, false);
|
||||
} else {
|
||||
mutateVerge();
|
||||
@@ -90,130 +102,93 @@ const TestPage = () => {
|
||||
mutateVerge({ ...verge, test_list: newList }, false);
|
||||
};
|
||||
|
||||
const reorder = (list: any[], startIndex: number, endIndex: number) => {
|
||||
const result = Array.from(list);
|
||||
const [removed] = result.splice(startIndex, 1);
|
||||
result.splice(endIndex, 0, removed);
|
||||
return result;
|
||||
};
|
||||
|
||||
const onDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (over) {
|
||||
if (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) {
|
||||
return;
|
||||
}
|
||||
let newList = reorder(testList, old_index, new_index);
|
||||
await mutateVerge({ ...verge, test_list: newList }, false);
|
||||
await patchVerge({ test_list: newList });
|
||||
}
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = testList.findIndex((x) => x.uid === active.id);
|
||||
const newIndex = testList.findIndex((x) => x.uid === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const newList = arrayMove(testList, oldIndex, newIndex);
|
||||
await mutateVerge({ ...verge, test_list: newList }, false);
|
||||
await patchVerge({ test_list: newList });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!verge) return;
|
||||
if (!verge?.test_list) {
|
||||
if (verge && !verge.test_list) {
|
||||
patchVerge({ test_list: testList });
|
||||
}
|
||||
}, [verge]);
|
||||
}, [verge, patchVerge, testList]);
|
||||
|
||||
const viewerRef = useRef<TestViewerRef>(null);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToTop = () => {
|
||||
containerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const handleScroll = (e: any) => {
|
||||
setShowScrollTop(e.target.scrollTop > 100);
|
||||
};
|
||||
const menuItems = [
|
||||
{ label: t("Home"), path: "/home" },
|
||||
{ label: t("Profiles"), path: "/profile" },
|
||||
{ label: t("Settings"), path: "/settings" },
|
||||
{ label: t("Logs"), path: "/logs" },
|
||||
{ label: t("Proxies"), path: "/proxies" },
|
||||
{ label: t("Connections"), path: "/connections" },
|
||||
{ label: t("Rules"), path: "/rules" },
|
||||
];
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
full
|
||||
title={t("Test")}
|
||||
header={
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => emit("verge://test-all")}
|
||||
>
|
||||
{t("Test All")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
onClick={() => viewerRef.current?.create()}
|
||||
>
|
||||
{t("New")}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
sx={{
|
||||
pt: 1.25,
|
||||
mb: 0.5,
|
||||
px: "10px",
|
||||
height: "calc(100vh - 100px)",
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<Box sx={{ mb: 4.5 }}>
|
||||
<Grid container spacing={{ xs: 1, lg: 1 }}>
|
||||
<SortableContext
|
||||
items={testList.map((x) => {
|
||||
return x.uid;
|
||||
})}
|
||||
>
|
||||
<div className="h-full w-full relative">
|
||||
<div className={cn("absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200", { "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled })}>
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">{t("Test")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => emit("verge://test-all")}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{t("Test All")}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => viewerRef.current?.create()}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("New")}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" title={t("Menu")}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t("Menu")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem key={item.path} onSelect={() => navigate(item.path)} disabled={location.pathname === item.path}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={scrollerRef} className="absolute top-0 left-0 right-0 bottom-0 pt-20 overflow-y-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7 gap-4">
|
||||
<SortableContext items={testList.map((x) => x.uid)}>
|
||||
{testList.map((item) => (
|
||||
<Grid
|
||||
component={"div"}
|
||||
size={{ xs: 6, lg: 2, sm: 4, md: 3 }}
|
||||
<TestItem
|
||||
key={item.uid}
|
||||
>
|
||||
<TestItem
|
||||
id={item.uid}
|
||||
itemData={item}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
onDelete={onDeleteTestListItem}
|
||||
/>
|
||||
</Grid>
|
||||
id={item.uid}
|
||||
itemData={item}
|
||||
onEdit={() => viewerRef.current?.edit(item)}
|
||||
onDelete={onDeleteTestListItem}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</Grid>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
<ScrollTopButton
|
||||
onClick={scrollToTop}
|
||||
show={showScrollTop}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: "20px",
|
||||
left: "20px",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ScrollTopButton onClick={scrollToTop} show={showScrollTop} />
|
||||
<TestViewer ref={viewerRef} onChange={onTestListItemChange} />
|
||||
</BasePage>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user