New Interface (initial commit)

This commit is contained in:
coolcoala
2025-07-04 02:28:27 +03:00
parent 4435a5aee4
commit 686490ded1
121 changed files with 12852 additions and 13274 deletions

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@root/src/components",
"utils": "@root/lib/utils",
"ui": "@root/src/components/ui",
"lib": "@root/lib",
"hooks": "@root/hooks"
},
"iconLibrary": "lucide"
}

6
lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -29,77 +29,117 @@
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.0",
"@hookform/resolvers": "^5.1.1",
"@juggle/resize-observer": "^3.4.0", "@juggle/resize-observer": "^3.4.0",
"@mui/icons-material": "^7.1.2", "@mui/icons-material": "^7.1.1",
"@mui/lab": "7.0.0-beta.14", "@mui/lab": "7.0.0-beta.13",
"@mui/material": "^7.1.2", "@mui/material": "^7.1.1",
"@mui/x-data-grid": "^8.6.0", "@mui/x-data-grid": "^8.5.1",
"@tauri-apps/api": "2.6.0", "@radix-ui/react-alert-dialog": "^1.1.14",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0", "@radix-ui/react-context-menu": "^2.2.15",
"@tauri-apps/plugin-dialog": "^2.3.0", "@radix-ui/react-dialog": "^1.1.14",
"@tauri-apps/plugin-fs": "^2.4.0", "@radix-ui/react-dropdown-menu": "^2.1.15",
"@tauri-apps/plugin-global-shortcut": "^2.3.0", "@radix-ui/react-icons": "^1.3.2",
"@tauri-apps/plugin-notification": "^2.3.0", "@radix-ui/react-label": "^2.1.7",
"@tauri-apps/plugin-process": "^2.3.0", "@radix-ui/react-popover": "^1.1.14",
"@tauri-apps/plugin-shell": "2.3.0", "@radix-ui/react-progress": "^1.1.7",
"@tauri-apps/plugin-updater": "2.9.0", "@radix-ui/react-select": "^2.2.5",
"@tauri-apps/plugin-window-state": "^2.3.0", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-clipboard-manager": "^2.2.2",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-global-shortcut": "^2.2.1",
"@tauri-apps/plugin-notification": "^2.2.2",
"@tauri-apps/plugin-process": "^2.2.1",
"@tauri-apps/plugin-shell": "2.2.1",
"@tauri-apps/plugin-updater": "2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2",
"@types/d3-shape": "^3.1.7",
"@types/json-schema": "^7.0.15", "@types/json-schema": "^7.0.15",
"ahooks": "^3.8.5", "ahooks": "^3.8.5",
"axios": "^1.10.0", "axios": "^1.9.0",
"chart.js": "^4.5.0", "chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"cli-color": "^2.0.4", "cli-color": "^2.0.4",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-shape": "^3.2.0",
"dayjs": "1.11.13", "dayjs": "1.11.13",
"foxact": "^0.2.49", "foxact": "^0.2.45",
"glob": "^11.0.3", "glob": "^11.0.2",
"i18next": "^25.2.1", "i18next": "^25.2.1",
"js-base64": "^3.7.7",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"json-schema": "^0.4.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lucide-react": "^0.514.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"monaco-yaml": "^5.4.0", "monaco-yaml": "^5.4.0",
"nanoid": "^5.1.5", "nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"peggy": "^5.0.3",
"react": "19.1.0", "react": "19.1.0",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-error-boundary": "6.0.0", "react-error-boundary": "6.0.0",
"react-hook-form": "^7.58.1", "react-hook-form": "^7.57.0",
"react-i18next": "15.5.3", "react-i18next": "15.5.2",
"react-markdown": "10.1.0", "react-markdown": "10.1.0",
"react-monaco-editor": "0.58.0", "react-monaco-editor": "0.58.0",
"react-router-dom": "7.6.2", "react-router-dom": "7.6.2",
"react-virtuoso": "^4.13.0", "react-virtuoso": "^4.12.8",
"sockette": "^2.0.6", "sockette": "^2.0.6",
"sonner": "^2.0.5",
"swr": "^2.3.3", "swr": "^2.3.3",
"tailwind-merge": "^3.3.1",
"tar": "^7.4.3", "tar": "^7.4.3",
"types-pac": "^1.0.3", "types-pac": "^1.0.3",
"zustand": "^5.0.6" "zod": "^3.25.67",
"zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@actions/github": "^6.0.1", "@actions/github": "^6.0.1",
"@tauri-apps/cli": "2.6.1", "@tauri-apps/cli": "2.5.0",
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/react": "19.1.8", "@types/node": "^24.0.0",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@vitejs/plugin-legacy": "^7.0.0", "@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-react": "4.6.0", "@vitejs/plugin-react": "4.5.1",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"autoprefixer": "^10.4.21",
"commander": "^14.0.0", "commander": "^14.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"https-proxy-agent": "^7.0.6", "https-proxy-agent": "^7.0.6",
"meta-json-schema": "^1.19.11", "husky": "^9.1.7",
"meta-json-schema": "^1.19.10",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"prettier": "^3.6.2", "postcss": "^8.5.4",
"prettier-plugin-organize-imports": "^4.1.0", "prettier": "^3.5.3",
"sass": "^1.89.2", "pretty-quick": "^4.2.2",
"terser": "^5.43.1", "sass": "^1.89.1",
"tailwindcss": "^4.1.11",
"terser": "^5.41.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^7.0.0", "vite": "^6.3.5",
"vite-plugin-monaco-editor": "^1.1.0", "vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^4.3.0" "vite-plugin-svgr": "^4.3.0"
}, },
"prettier": {
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"endOfLine": "lf"
},
"type": "module", "type": "module",
"packageManager": "pnpm@9.13.2" "packageManager": "pnpm@9.13.2"
} }

2120
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,7 @@
import { AppDataProvider } from "./providers/app-data-provider"; import { AppDataProvider } from "./providers/app-data-provider";
import Layout from "./pages/_layout"; import Layout from "./pages/_layout";
import { useNotificationPermission } from "./hooks/useNotificationPermission";
function App() { function App() {
useNotificationPermission();
return ( return (
<AppDataProvider> <AppDataProvider>
<Layout /> <Layout />

View File

@@ -1,68 +1,30 @@
import React, { useSyncExternalStore } from "react"; "use client";
import { Snackbar, Alert, IconButton, Box } from "@mui/material";
import { CloseRounded } from "@mui/icons-material"; import { Toaster, toast } from "sonner";
import { useEffect, useSyncExternalStore } from "react";
import { import {
subscribeNotices,
hideNotice,
getSnapshotNotices, getSnapshotNotices,
hideNotice,
subscribeNotices,
} from "@/services/noticeService"; } from "@/services/noticeService";
export const NoticeManager: React.FC = () => { export const NoticeManager = () => {
const currentNotices = useSyncExternalStore( const currentNotices = useSyncExternalStore(
subscribeNotices, subscribeNotices,
getSnapshotNotices, getSnapshotNotices,
); );
const handleClose = (id: number) => { useEffect(() => {
hideNotice(id); for (const notice of currentNotices) {
}; const toastId = toast(notice.message, {
id: notice.id,
duration: notice.duration,
onDismiss: (t) => {
hideNotice(t.id as number);
},
});
}
}, [currentNotices]);
return ( return <Toaster />;
<Box
sx={{
position: "fixed",
top: "20px",
right: "20px",
zIndex: 1500,
display: "flex",
flexDirection: "column",
gap: "10px",
maxWidth: "360px",
}}
>
{currentNotices.map((notice) => (
<Snackbar
key={notice.id}
open={true}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
sx={{
position: "relative",
transform: "none",
top: "auto",
right: "auto",
bottom: "auto",
left: "auto",
width: "100%",
}}
>
<Alert
severity={notice.type}
variant="filled"
sx={{ width: "100%" }}
action={
<IconButton
size="small"
color="inherit"
onClick={() => handleClose(notice.id)}
>
<CloseRounded fontSize="inherit" />
</IconButton>
}
>
{notice.message}
</Alert>
</Snackbar>
))}
</Box>
);
}; };

View File

@@ -1,15 +1,18 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import { useTranslation } from "react-i18next";
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
type SxProps,
type Theme,
} from "@mui/material";
import { LoadingButton } from "@mui/lab";
// --- Новые импорты ---
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; // Иконка для спиннера
// --- Интерфейсы ---
interface Props { interface Props {
title: ReactNode; title: ReactNode;
open: boolean; open: boolean;
@@ -18,12 +21,12 @@ interface Props {
disableOk?: boolean; disableOk?: boolean;
disableCancel?: boolean; disableCancel?: boolean;
disableFooter?: boolean; disableFooter?: boolean;
contentSx?: SxProps<Theme>; className?: string; // Замена для contentSx, чтобы передавать классы Tailwind
children?: ReactNode; children?: ReactNode;
loading?: boolean; loading?: boolean;
onOk?: () => void; onOk?: () => void;
onCancel?: () => void; onCancel?: () => void;
onClose?: () => void; onClose?: () => void; // onOpenChange в shadcn/ui делает то же самое
} }
export interface DialogRef { export interface DialogRef {
@@ -38,37 +41,44 @@ export const BaseDialog: React.FC<Props> = (props) => {
children, children,
okBtn, okBtn,
cancelBtn, cancelBtn,
contentSx, className,
disableCancel, disableCancel,
disableOk, disableOk,
disableFooter, disableFooter,
loading, loading,
onClose,
onCancel,
onOk,
} = props; } = props;
const { t } = useTranslation();
return ( return (
<Dialog open={open} onClose={props.onClose}> // Управляем состоянием через onOpenChange, которое вызывает onClose
<DialogTitle>{title}</DialogTitle> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose?.()}>
<DialogContent className={className}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<DialogContent sx={contentSx}>{children}</DialogContent> {children}
{!disableFooter && ( {!disableFooter && (
<DialogActions> <DialogFooter>
{!disableCancel && ( {!disableCancel && (
<Button variant="outlined" onClick={props.onCancel}> <Button variant="outline" onClick={onCancel} disabled={loading}>
{cancelBtn} {cancelBtn || t("Cancel")}
</Button> </Button>
)} )}
{!disableOk && ( {!disableOk && (
<LoadingButton <Button disabled={loading || disableOk} onClick={onOk}>
loading={loading} {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
variant="contained" {okBtn || t("Confirm")}
onClick={props.onOk} </Button>
> )}
{okBtn} </DialogFooter>
</LoadingButton> )}
)} </DialogContent>
</DialogActions>
)}
</Dialog> </Dialog>
); );
}; };

View File

@@ -1,10 +1,10 @@
import { alpha, Box, Typography } from "@mui/material"; import { ReactNode } from "react";
import { InboxRounded } from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Inbox } from "lucide-react"; // 1. Импортируем иконку из lucide-react
interface Props { interface Props {
text?: React.ReactNode; text?: ReactNode;
extra?: React.ReactNode; extra?: ReactNode;
} }
export const BaseEmpty = (props: Props) => { export const BaseEmpty = (props: Props) => {
@@ -12,20 +12,15 @@ export const BaseEmpty = (props: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box // 2. Заменяем Box на div и переводим sx в классы Tailwind
sx={({ palette }) => ({ <div className="flex h-full w-full flex-col items-center justify-center space-y-4 text-muted-foreground/75">
width: "100%", {/* 3. Заменяем иконку MUI на lucide-react и задаем размер классами */}
height: "100%", <Inbox className="h-20 w-20" />
display: "flex",
flexDirection: "column", {/* 4. Заменяем Typography на p */}
alignItems: "center", <p className="text-xl">{t(`${text}`)}</p>
justifyContent: "center",
color: alpha(palette.text.secondary, 0.75),
})}
>
<InboxRounded sx={{ fontSize: "4em" }} />
<Typography sx={{ fontSize: "1.25em" }}>{t(`${text}`)}</Typography>
{extra} {extra}
</Box> </div>
); );
}; };

View File

@@ -1,16 +1,30 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ErrorBoundary, FallbackProps } from "react-error-boundary"; import { ErrorBoundary, FallbackProps } from "react-error-boundary";
import { useTranslation } from "react-i18next";
import { AlertTriangle } from "lucide-react"; // Импортируем иконку
// Новый, стилизованный компонент для отображения ошибки
function ErrorFallback({ error }: FallbackProps) { function ErrorFallback({ error }: FallbackProps) {
const { t } = useTranslation();
return ( return (
<div role="alert" style={{ padding: 16 }}> <div role="alert" className="m-4 rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
<h4>Something went wrong:(</h4> <div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
<h3 className="font-semibold">{t("Something went wrong")}</h3>
</div>
<pre>{error.message}</pre> <pre className="mt-2 whitespace-pre-wrap rounded-md bg-destructive/10 p-2 text-xs font-mono">
{error.message}
</pre>
<details title="Error Stack"> <details className="mt-4">
<summary>Error Stack</summary> <summary className="cursor-pointer text-xs font-medium hover:underline">
<pre>{error.stack}</pre> {t("Error Stack")}
</summary>
<pre className="mt-2 whitespace-pre-wrap rounded-md bg-muted p-2 text-xs font-mono text-muted-foreground">
{error.stack}
</pre>
</details> </details>
</div> </div>
); );

View File

@@ -1,38 +1,30 @@
import React from "react"; import React from "react";
import { Box, styled } from "@mui/material"; import { cn } from "@root/lib/utils"; // Импортируем утилиту для объединения классов
type Props = { type Props = {
label: string; label: string;
fontSize?: string;
width?: string;
padding?: string;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; // Пропс для дополнительной стилизации
}; };
export const BaseFieldset: React.FC<Props> = (props: Props) => { export const BaseFieldset: React.FC<Props> = (props) => {
const Fieldset = styled(Box)<{ component?: string }>(() => ({ const { label, children, className } = props;
position: "relative",
border: "1px solid #bbb",
borderRadius: "5px",
width: props.width ?? "auto",
padding: props.padding ?? "15px",
}));
const Label = styled("legend")(({ theme }) => ({
position: "absolute",
top: "-10px",
left: props.padding ?? "15px",
backgroundColor: theme.palette.background.paper,
backgroundImage:
"linear-gradient(rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.16))",
color: theme.palette.text.primary,
fontSize: props.fontSize ?? "1em",
}));
return ( return (
<Fieldset component="fieldset"> // 1. Используем тег fieldset для семантики. Он позиционирован как relative.
<Label>{props.label}</Label> <fieldset
{props.children} className={cn(
</Fieldset> "relative rounded-md border border-border p-4", // Базовые стили
className // Дополнительные классы от пользователя
)}
>
{/* 2. Используем legend. Он абсолютно спозиционирован относительно fieldset. */}
<legend className="absolute -top-2.5 left-3 bg-background px-1 text-sm text-muted-foreground">
{label}
</legend>
{/* 3. Здесь будет содержимое филдсета */}
{children}
</fieldset>
); );
}; };

View File

@@ -1,32 +1,29 @@
import React from "react"; import React from "react";
import { Box, CircularProgress } from "@mui/material"; import { BaseLoading } from "./base-loading"; // 1. Импортируем наш собственный компонент загрузки
import { cn } from "@root/lib/utils";
export interface BaseLoadingOverlayProps { export interface BaseLoadingOverlayProps {
isLoading: boolean; isLoading: boolean;
className?: string;
} }
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({ export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading, isLoading,
className,
}) => { }) => {
if (!isLoading) return null; if (!isLoading) return null;
return ( return (
<Box // 2. Заменяем Box на div и переводим sx в классы Tailwind
sx={{ <div
position: "absolute", className={cn(
top: 0, "absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-sm",
left: 0, className
right: 0, )}
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(255, 255, 255, 0.7)",
zIndex: 1000,
}}
> >
<CircularProgress /> {/* 3. Используем наш BaseLoading и делаем его немного больше */}
</Box> <BaseLoading className="h-8 w-8 text-primary" />
</div>
); );
}; };

View File

@@ -1,48 +1,14 @@
import { styled } from "@mui/material"; import { Loader2 } from "lucide-react"; // 1. Импортируем стандартную иконку загрузки
import { cn } from "@root/lib/utils"; // Утилита для объединения классов
const Loading = styled("div")` interface Props {
position: relative; className?: string;
display: flex; }
height: 100%;
min-height: 18px;
box-sizing: border-box;
align-items: center;
& > div { export const BaseLoading: React.FC<Props> = ({ className }) => {
box-sizing: border-box;
width: 6px;
height: 6px;
margin: 2px;
border-radius: 100%;
animation: loading 0.7s -0.15s infinite linear;
}
& > div:nth-child(2n-1) {
animation-delay: -0.5s;
}
@keyframes loading {
50% {
opacity: 0.2;
transform: scale(0.75);
}
100% {
opacity: 1;
transform: scale(1);
}
}
`;
const LoadingItem = styled("div")(({ theme }) => ({
background: theme.palette.text.secondary,
}));
export const BaseLoading = () => {
return ( return (
<Loading> // 2. Используем иконку с анимацией вращения от Tailwind
<LoadingItem /> // Мы можем легко менять ее размер и цвет через className
<LoadingItem /> <Loader2 className={cn("h-5 w-5 animate-spin", className)} />
<LoadingItem />
</Loading>
); );
}; };

View File

@@ -1,50 +1,40 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Typography } from "@mui/material";
import { BaseErrorBoundary } from "./base-error-boundary"; import { BaseErrorBoundary } from "./base-error-boundary";
import { useTheme } from "@mui/material/styles"; import { cn } from "@root/lib/utils";
interface Props { interface Props {
title?: React.ReactNode; // the page title title?: ReactNode; // Заголовок страницы
header?: React.ReactNode; // something behind title header?: ReactNode; // Элементы в правой части шапки (кнопки и т.д.)
contentStyle?: React.CSSProperties; children?: ReactNode; // Основное содержимое страницы
children?: ReactNode; className?: string; // Дополнительные классы для основной области контента
full?: boolean;
} }
export const BasePage: React.FC<Props> = (props) => { export const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, full, children } = props; const { title, header, children, className } = props;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
return ( return (
<BaseErrorBoundary> <BaseErrorBoundary>
<div className="base-page"> {/* 1. Корневой контейнер: flex-колонка на всю высоту */}
<header data-tauri-drag-region="true" style={{ userSelect: "none" }}> <div className="h-full flex flex-col bg-background text-foreground">
<Typography
sx={{ fontSize: "20px", fontWeight: "700 " }}
data-tauri-drag-region="true"
>
{title}
</Typography>
{header} {/* 2. Шапка: не растягивается, имеет фиксированную высоту и нижнюю границу */}
<header
data-tauri-drag-region="true"
className="flex-shrink-0 flex items-center justify-between h-16 px-4 border-b border-border"
>
<h2 className="text-xl font-bold" data-tauri-drag-region="true">
{title}
</h2>
<div data-tauri-drag-region="true">
{header}
</div>
</header> </header>
<div {/* 3. Основная область: занимает все оставшееся место и прокручивается */}
className={full ? "base-container no-padding" : "base-container"} <main className={cn("flex-1 overflow-y-auto min-h-0", className)}>
style={{ backgroundColor: isDark ? "#1e1f27" : "#ffffff" }} {children}
> </main>
<section
style={{
backgroundColor: isDark ? "#1e1f27" : "var(--background-color)",
}}
>
<div className="base-content" style={contentStyle}>
{children}
</div>
</section>
</div>
</div> </div>
</BaseErrorBoundary> </BaseErrorBoundary>
); );

View File

@@ -1,11 +1,12 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material"; import { ChangeEvent, useEffect, useMemo, useState } from "react";
import Tooltip from "@mui/material/Tooltip";
import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react"; import { cn } from "@root/lib/utils";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react"; // Новые импорты
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { CaseSensitive, WholeWord, Regex } from "lucide-react"; // Иконки из lucide-react
export type SearchState = { export type SearchState = {
text: string; text: string;
@@ -16,150 +17,97 @@ export type SearchState = {
type SearchProps = { type SearchProps = {
placeholder?: string; placeholder?: string;
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
onSearch: (match: (content: string) => boolean, state: SearchState) => void; onSearch: (match: (content: string) => boolean, state: SearchState) => void;
}; };
const StyledTextField = styled(TextField)(({ theme }) => ({
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined,
paddingRight: "4px",
},
"& .MuiInputBase-root svg[aria-label='active'] path": {
fill: theme.palette.primary.light,
},
"& .MuiInputBase-root svg[aria-label='inactive'] path": {
fill: "#A7A7A7",
},
}));
export const BaseSearchBox = (props: SearchProps) => { export const BaseSearchBox = (props: SearchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null); const [text, setText] = useState("");
const [matchCase, setMatchCase] = useState(props.matchCase ?? false); const [matchCase, setMatchCase] = useState(false);
const [matchWholeWord, setMatchWholeWord] = useState( const [matchWholeWord, setMatchWholeWord] = useState(false);
props.matchWholeWord ?? false, const [useRegularExpression, setUseRegularExpression] = useState(false);
);
const [useRegularExpression, setUseRegularExpression] = useState(
props.useRegularExpression ?? false,
);
const [errorMessage, setErrorMessage] = useState(""); const [errorMessage, setErrorMessage] = useState("");
const iconStyle = {
style: {
height: "24px",
width: "24px",
cursor: "pointer",
} as React.CSSProperties,
inheritViewBox: true,
};
const createMatcher = useMemo(() => { const createMatcher = useMemo(() => {
return (searchText: string) => { return (searchText: string) => {
try { try {
setErrorMessage(""); // Сбрасываем ошибку при новой попытке
return (content: string) => { return (content: string) => {
if (!searchText) return true; if (!searchText) return true;
const flags = matchCase ? "" : "i";
let item = !matchCase ? content.toLowerCase() : content;
let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
if (useRegularExpression) { if (useRegularExpression) {
return new RegExp(searchItem).test(item); return new RegExp(searchText, flags).test(content);
} }
let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // Экранируем спецсимволы
if (matchWholeWord) { if (matchWholeWord) {
return new RegExp(`\\b${searchItem}\\b`).test(item); pattern = `\\b${pattern}\\b`;
} }
return item.includes(searchItem); return new RegExp(pattern, flags).test(content);
}; };
} catch (err) { } catch (err: any) {
setErrorMessage(`${err}`); setErrorMessage(err.message);
return () => true; return () => true; // Возвращаем "безопасный" матчер в случае ошибки
} }
}; };
}, [matchCase, matchWholeWord, useRegularExpression]); }, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => { useEffect(() => {
if (!inputRef.current) return; props.onSearch(createMatcher(text), { text, matchCase, matchWholeWord, useRegularExpression });
const value = inputRef.current.value; }, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); // Убрали text из зависимостей
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target?.value ?? ""; const value = e.target.value;
setErrorMessage(""); setText(value);
props.onSearch(createMatcher(value), { props.onSearch(createMatcher(value), { text: value, matchCase, matchWholeWord, useRegularExpression });
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}; };
const getToggleVariant = (isActive: boolean) => (isActive ? "secondary" : "ghost");
return ( return (
<Tooltip title={errorMessage} placement="bottom-start"> <div className="w-full">
<StyledTextField <div className="relative">
autoComplete="new-password" {/* Добавляем правый отступ, чтобы текст не заезжал под иконки */}
inputRef={inputRef} <Input
hiddenLabel placeholder={props.placeholder ?? t("Filter conditions")}
fullWidth value={text}
size="small" onChange={handleChange}
variant="outlined" className="pr-28" // pr-[112px]
spellCheck="false" />
placeholder={props.placeholder ?? t("Filter conditions")} {/* Контейнер для иконок, абсолютно спозиционированный справа */}
sx={{ input: { py: 0.65, px: 1.25 } }} <div className="absolute inset-y-0 right-0 flex items-center pr-2">
onChange={onChange} <TooltipProvider delayDuration={100}>
slotProps={{ <Tooltip>
input: { <TooltipTrigger asChild>
sx: { pr: 1 }, <Button variant={getToggleVariant(matchCase)} size="icon" className="h-7 w-7" onClick={() => setMatchCase(!matchCase)}>
endAdornment: ( <CaseSensitive className="h-4 w-4" />
<Box display="flex"> </Button>
<Tooltip title={t("Match Case")}> </TooltipTrigger>
<div> <TooltipContent><p>{t("Match Case")}</p></TooltipContent>
<SvgIcon </Tooltip>
component={matchCaseIcon} <Tooltip>
{...iconStyle} <TooltipTrigger asChild>
aria-label={matchCase ? "active" : "inactive"} <Button variant={getToggleVariant(matchWholeWord)} size="icon" className="h-7 w-7" onClick={() => setMatchWholeWord(!matchWholeWord)}>
onClick={() => setMatchCase(!matchCase)} <WholeWord className="h-4 w-4" />
/> </Button>
</div> </TooltipTrigger>
</Tooltip> <TooltipContent><p>{t("Match Whole Word")}</p></TooltipContent>
<Tooltip title={t("Match Whole Word")}> </Tooltip>
<div> <Tooltip>
<SvgIcon <TooltipTrigger asChild>
component={matchWholeWordIcon} <Button variant={getToggleVariant(useRegularExpression)} size="icon" className="h-7 w-7" onClick={() => setUseRegularExpression(!useRegularExpression)}>
{...iconStyle} <Regex className="h-4 w-4" />
aria-label={matchWholeWord ? "active" : "inactive"} </Button>
onClick={() => setMatchWholeWord(!matchWholeWord)} </TooltipTrigger>
/> <TooltipContent><p>{t("Use Regular Expression")}</p></TooltipContent>
</div> </Tooltip>
</Tooltip> </TooltipProvider>
<Tooltip title={t("Use Regular Expression")}> </div>
<div> </div>
<SvgIcon {/* Отображение ошибки под полем ввода */}
component={useRegularExpressionIcon} {errorMessage && <p className="mt-1 text-xs text-destructive">{errorMessage}</p>}
aria-label={useRegularExpression ? "active" : "inactive"} </div>
{...iconStyle}
onClick={() =>
setUseRegularExpression(!useRegularExpression)
}
/>{" "}
</div>
</Tooltip>
</Box>
),
},
}}
/>
</Tooltip>
); );
}; };

View File

@@ -1,19 +1,37 @@
import { Select, SelectProps, styled } from "@mui/material"; import * as React from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@root/lib/utils";
// Определяем новые пропсы для нашего компонента
export interface BaseStyledSelectProps {
children: React.ReactNode; // Сюда будут передаваться <SelectItem>
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
className?: string; // для дополнительной стилизации
}
export const BaseStyledSelect: React.FC<BaseStyledSelectProps> = (props) => {
const { value, onValueChange, placeholder, children, className } = props;
export const BaseStyledSelect = styled((props: SelectProps<string>) => {
return ( return (
<Select // Используем композицию компонентов Select из shadcn/ui
size="small" <Select value={value} onValueChange={onValueChange}>
autoComplete="new-password" <SelectTrigger
sx={{ className={cn(
width: 120, "h-9 w-[180px]", // Задаем стандартные размеры, как у других селектов
height: 33.375, className
mr: 1, )}
'[role="button"]': { py: 0.65 }, >
}} <SelectValue placeholder={placeholder} />
{...props} </SelectTrigger>
/> <SelectContent>{children}</SelectContent>
</Select>
); );
})(({ theme }) => ({ };
background: theme.palette.mode === "light" ? "#fff" : undefined,
}));

View File

@@ -1,24 +1,32 @@
import { TextField, type TextFieldProps, styled } from "@mui/material"; import * as React from "react"; // 1. Убедимся, что React импортирован
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { cn } from "@root/lib/utils";
import { Input } from "@/components/ui/input"; // 2. Убираем импорт несуществующего типа InputProps
export const BaseStyledTextField = styled((props: TextFieldProps) => { // 3. Определяем наши пропсы, расширяя стандартный тип для input-элементов из React
export interface BaseStyledTextFieldProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export const BaseStyledTextField = React.forwardRef<
HTMLInputElement,
BaseStyledTextFieldProps // Используем наш правильный тип
>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { className, ...restProps } = props;
return ( return (
<TextField <Input
autoComplete="new-password" ref={ref}
hiddenLabel className={cn(
fullWidth "h-9", // Задаем стандартную компактную высоту
size="small" className
variant="outlined" )}
placeholder={props.placeholder ?? t("Filter conditions")}
autoComplete="off"
spellCheck="false" spellCheck="false"
placeholder={t("Filter conditions")} {...restProps}
sx={{ input: { py: 0.65, px: 1.25 } }}
{...props}
/> />
); );
})(({ theme }) => ({ });
"& .MuiInputBase-root": {
background: theme.palette.mode === "light" ? "#fff" : undefined, BaseStyledTextField.displayName = "BaseStyledTextField";
},
}));

View File

@@ -1,58 +1,23 @@
import { styled } from "@mui/material/styles"; import * as React from "react";
import { default as MuiSwitch, SwitchProps } from "@mui/material/Switch"; import { Switch as ShadcnSwitch } from "@/components/ui/switch";
import { cn } from "@root/lib/utils";
export const Switch = styled((props: SwitchProps) => ( // Тип пропсов остается без изменений
<MuiSwitch export type SwitchProps = React.ComponentPropsWithoutRef<typeof ShadcnSwitch>;
focusVisibleClassName=".Mui-focusVisible"
disableRipple const Switch = React.forwardRef<
{...props} HTMLButtonElement,
/> SwitchProps
))(({ theme }) => ({ >(({ className, ...props }, ref) => {
width: 42, return (
height: 26, <ShadcnSwitch
padding: 0, className={cn(className)}
marginRight: 1, ref={ref}
"& .MuiSwitch-switchBase": { {...props}
padding: 0, />
margin: 2, );
transitionDuration: "300ms", });
"&.Mui-checked": {
transform: "translateX(16px)", Switch.displayName = "Switch";
color: "#fff",
"& + .MuiSwitch-track": { export { Switch };
backgroundColor: theme.palette.primary.main,
opacity: 1,
border: 0,
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: 0.5,
},
},
"&.Mui-focusVisible .MuiSwitch-thumb": {
color: "#33cf4d",
border: "6px solid #fff",
},
"&.Mui-disabled .MuiSwitch-thumb": {
color:
theme.palette.mode === "light"
? theme.palette.grey[100]
: theme.palette.grey[600],
},
"&.Mui-disabled + .MuiSwitch-track": {
opacity: theme.palette.mode === "light" ? 0.7 : 0.3,
},
},
"& .MuiSwitch-thumb": {
boxSizing: "border-box",
width: 22,
height: 22,
},
"& .MuiSwitch-track": {
borderRadius: 26 / 2,
backgroundColor: theme.palette.mode === "light" ? "#BBBBBB" : "#39393D",
opacity: 1,
transition: theme.transitions.create(["background-color"], {
duration: 500,
}),
},
}));

View File

@@ -1,24 +1,52 @@
import * as React from "react";
import { cn } from "@root/lib/utils";
// 1. Убираем импорт несуществующего типа ButtonProps
import { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
IconButton, TooltipContent,
IconButtonProps, TooltipProvider,
SvgIconProps, TooltipTrigger,
} from "@mui/material"; } from "@/components/ui/tooltip";
import { InfoRounded } from "@mui/icons-material"; import { Info } from "lucide-react";
interface Props extends IconButtonProps { // 2. Определяем наши пропсы, расширяя стандартный тип для кнопок из React
title?: string; export interface TooltipIconProps
icon?: React.ElementType<SvgIconProps>; extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tooltip: React.ReactNode;
icon?: React.ReactNode;
} }
export const TooltipIcon: React.FC<Props> = (props: Props) => { export const TooltipIcon = React.forwardRef<
const { title = "", icon: Icon = InfoRounded, ...restProps } = props; HTMLButtonElement,
TooltipIconProps
>(({ tooltip, icon, className, ...props }, ref) => {
const displayIcon = icon || <Info className="h-4 w-4" />;
return ( return (
<Tooltip title={title} placement="top"> <TooltipProvider>
<IconButton color="inherit" size="small" {...restProps}> <Tooltip>
<Icon fontSize="inherit" style={{ cursor: "pointer", opacity: 0.75 }} /> <TooltipTrigger asChild>
</IconButton> <Button
</Tooltip> ref={ref}
variant="ghost"
size="icon"
className={cn("h-7 w-7 text-muted-foreground", className)}
{...props}
>
{displayIcon}
<span className="sr-only">
{typeof tooltip === "string" ? tooltip : "Icon button"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent>
{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
); );
}; });
TooltipIcon.displayName = "TooltipIcon";

View File

@@ -1,10 +1,16 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Box, Button, Snackbar, useTheme } from "@mui/material"; import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { deleteConnection } from "@/services/api"; import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next"; import { t } from "i18next";
import { Button } from "@/components/ui/button";
export interface ConnectionDetailRef { export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void; open: (detail: IConnectionsItem) => void;
@@ -14,38 +20,37 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
(props, ref) => { (props, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!); const [detail, setDetail] = useState<IConnectionsItem>(null!);
const theme = useTheme();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem) => { open: (detail: IConnectionsItem) => {
if (open) return;
setOpen(true);
setDetail(detail); setDetail(detail);
setOpen(true);
}, },
})); }));
const onClose = () => setOpen(false); const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
};
if (!detail) return null;
return ( return (
<Snackbar <Sheet open={open} onOpenChange={handleOpenChange}>
anchorOrigin={{ vertical: "bottom", horizontal: "right" }} <SheetContent
open={open} side="right"
onClose={onClose} className="w-full max-w-[520px] max-h-[100vh] sm:max-h-[calc(100vh-2rem)] overflow-y-auto p-0 flex flex-col"
sx={{ >
".MuiSnackbarContent-root": { <SheetHeader className="p-6 pb-4">
maxWidth: "520px", <SheetTitle>{t("Connection Details")}</SheetTitle>
maxHeight: "480px", </SheetHeader>
overflowY: "auto", <div className="flex-grow overflow-y-auto p-6 pt-0">
backgroundColor: theme.palette.background.paper, <InnerConnectionDetail
color: theme.palette.text.primary, data={detail}
}, onClose={() => setOpen(false)}
}} />
message={ </div>
detail ? ( </SheetContent>
<InnerConnectionDetail data={detail} onClose={onClose} /> </Sheet>
) : null
}
/>
); );
}, },
); );
@@ -57,7 +62,6 @@ interface InnerProps {
const InnerConnectionDetail = ({ data, onClose }: InnerProps) => { const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const { metadata, rulePayload } = data; const { metadata, rulePayload } = data;
const theme = useTheme();
const chains = [...data.chains].reverse().join(" / "); const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule; const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host const host = metadata.host
@@ -86,7 +90,9 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
{ label: t("Rule"), value: rule }, { label: t("Rule"), value: rule },
{ {
label: t("Process"), label: t("Process"),
value: `${metadata.process}${metadata.processPath ? `(${metadata.processPath})` : ""}`, value: `${metadata.process}${
metadata.processPath ? `(${metadata.processPath})` : ""
}`,
}, },
{ label: t("Time"), value: dayjs(data.start).fromNow() }, { label: t("Time"), value: dayjs(data.start).fromNow() },
{ {
@@ -101,24 +107,16 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const onDelete = useLockFn(async () => deleteConnection(data.id)); const onDelete = useLockFn(async () => deleteConnection(data.id));
return ( return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}> <div className="select-text text-muted-foreground">
{information.map((each) => ( {information.map((each) => (
<div key={each.label}> <div key={each.label} className="mb-1">
<b>{each.label}</b> <b className="text-foreground">{each.label}</b>
<span <span className="break-all text-foreground">: {each.value}</span>
style={{
wordBreak: "break-all",
color: theme.palette.text.primary,
}}
>
: {each.value}
</span>
</div> </div>
))} ))}
<Box sx={{ textAlign: "right" }}> <div className="text-right mt-4">
<Button <Button
variant="contained"
title={t("Close Connection")} title={t("Close Connection")}
onClick={() => { onClick={() => {
onDelete(); onDelete();
@@ -127,7 +125,7 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
> >
{t("Close Connection")} {t("Close Connection")}
</Button> </Button>
</Box> </div>
</Box> </div>
); );
}; };

View File

@@ -1,27 +1,22 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { import { X } from "lucide-react";
styled, import { Button } from "@/components/ui/button";
ListItem,
IconButton,
ListItemText,
Box,
alpha,
} from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
import { deleteConnection } from "@/services/api"; import { deleteConnection } from "@/services/api";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
const Tag = styled("span")(({ theme }) => ({ interface TagProps {
fontSize: "10px", children: React.ReactNode;
padding: "0 4px", className?: string;
lineHeight: 1.375, }
border: "1px solid",
borderRadius: 4, const Tag: React.FC<TagProps> = ({ children, className }) => {
borderColor: alpha(theme.palette.text.secondary, 0.35), const baseClasses =
marginTop: "4px", "text-[10px] px-1 leading-[1.375] border rounded-[4px] border-muted-foreground/35";
marginRight: "4px", return (
})); <span className={`${baseClasses} ${className || ""}`}>{children}</span>
);
};
interface Props { interface Props {
value: IConnectionsItem; value: IConnectionsItem;
@@ -37,43 +32,42 @@ export const ConnectionItem = (props: Props) => {
const showTraffic = curUpload! >= 100 || curDownload! >= 100; const showTraffic = curUpload! >= 100 || curDownload! >= 100;
return ( return (
<ListItem <div className="flex items-center justify-between p-3 border-b border-border dark:border-border">
dense <div
sx={{ borderBottom: "1px solid var(--divider-color)" }} className="flex-grow select-text cursor-pointer mr-2"
secondaryAction={
<IconButton edge="end" color="inherit" onClick={onDelete}>
<CloseRounded />
</IconButton>
}
>
<ListItemText
sx={{ userSelect: "text", cursor: "pointer" }}
primary={metadata.host || metadata.destinationIP}
onClick={onShowDetail} onClick={onShowDetail}
secondary={ >
<Box sx={{ display: "flex", flexWrap: "wrap" }}> <div className="text-sm font-medium text-foreground">
<Tag sx={{ textTransform: "uppercase", color: "success" }}> {metadata.host || metadata.destinationIP}
{metadata.network} </div>
<div className="flex flex-wrap gap-1 mt-1">
<Tag className="uppercase text-green-600 dark:text-green-500">
{metadata.network}
</Tag>
<Tag>{metadata.type}</Tag>
{!!metadata.process && <Tag>{metadata.process}</Tag>}
{chains?.length > 0 && <Tag>{[...chains].reverse().join(" / ")}</Tag>}
<Tag>{dayjs(start).fromNow()}</Tag>
{showTraffic && (
<Tag>
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag> </Tag>
)}
<Tag>{metadata.type}</Tag> </div>
</div>
{!!metadata.process && <Tag>{metadata.process}</Tag>} <Button
variant="ghost"
{chains?.length > 0 && ( size="icon"
<Tag>{[...chains].reverse().join(" / ")}</Tag> onClick={onDelete}
)} className="ml-2 flex-shrink-0"
>
<Tag>{dayjs(start).fromNow()}</Tag> <X className="h-4 w-4" />
</Button>
{showTraffic && ( </div>
<Tag>
{parseTraffic(curUpload!)} / {parseTraffic(curDownload!)}
</Tag>
)}
</Box>
}
/>
</ListItem>
); );
}; };

View File

@@ -1,139 +1,73 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useMemo, useState, useEffect } from "react"; import relativeTime from "dayjs/plugin/relativeTime";
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid"; import React, { useMemo, useState, useEffect, RefObject } from "react";
import { useThemeMode } from "@/services/states"; import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
Row,
ColumnSizingState,
} from "@tanstack/react-table";
import { TableVirtuoso, TableComponents } from "react-virtuoso";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { truncateStr } from "@/utils/truncate-str"; import { truncateStr } from "@/utils/truncate-str";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next"; import { t } from "i18next";
import { cn } from "@root/lib/utils";
dayjs.extend(relativeTime);
// Интерфейс для строки данных, которую использует react-table
interface ConnectionRow {
id: string;
host: string;
download: number;
upload: number;
dlSpeed: number;
ulSpeed: number;
chains: string;
rule: string;
process: string;
time: string;
source: string;
remoteDestination: string;
type: string;
connectionData: IConnectionsItem;
}
// Интерфейс для пропсов, которые компонент получает от родителя
interface Props { interface Props {
connections: IConnectionsItem[]; connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void; onShowDetail: (data: IConnectionsItem) => void;
scrollerRef: (element: HTMLElement | Window | null) => void;
} }
export const ConnectionTable = (props: Props) => { export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props; const { connections, onShowDetail, scrollerRef } = props;
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
const backgroundColor = isDark ? "#282A36" : "#ffffff";
const [columnVisible, setColumnVisible] = useState< const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
Partial<Record<keyof IConnectionsItem, boolean>> try {
>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const saved = localStorage.getItem("connection-table-widths"); const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {}; return saved ? JSON.parse(saved) : {};
}, } catch { return {}; }
); });
const [columns] = useState<GridColDef[]>([
{
field: "host",
headerName: t("Host"),
width: columnWidths["host"] || 220,
minWidth: 180,
},
{
field: "download",
headerName: t("Downloaded"),
width: columnWidths["download"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
},
{
field: "upload",
headerName: t("Uploaded"),
width: columnWidths["upload"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" "),
},
{
field: "dlSpeed",
headerName: t("DL Speed"),
width: columnWidths["dlSpeed"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
},
{
field: "ulSpeed",
headerName: t("UL Speed"),
width: columnWidths["ulSpeed"] || 88,
align: "right",
headerAlign: "right",
valueFormatter: (value: number) => parseTraffic(value).join(" ") + "/s",
},
{
field: "chains",
headerName: t("Chains"),
width: columnWidths["chains"] || 340,
minWidth: 180,
},
{
field: "rule",
headerName: t("Rule"),
width: columnWidths["rule"] || 280,
minWidth: 180,
},
{
field: "process",
headerName: t("Process"),
width: columnWidths["process"] || 220,
minWidth: 180,
},
{
field: "time",
headerName: t("Time"),
width: columnWidths["time"] || 120,
minWidth: 100,
align: "right",
headerAlign: "right",
sortComparator: (v1: string, v2: string) =>
new Date(v2).getTime() - new Date(v1).getTime(),
valueFormatter: (value: number) => dayjs(value).fromNow(),
},
{
field: "source",
headerName: t("Source"),
width: columnWidths["source"] || 200,
minWidth: 130,
},
{
field: "remoteDestination",
headerName: t("Destination"),
width: columnWidths["remoteDestination"] || 200,
minWidth: 130,
},
{
field: "type",
headerName: t("Type"),
width: columnWidths["type"] || 160,
minWidth: 100,
},
]);
useEffect(() => { useEffect(() => {
console.log("Saving column widths:", columnWidths); localStorage.setItem("connection-table-widths", JSON.stringify(columnSizing));
localStorage.setItem( }, [columnSizing]);
"connection-table-widths",
JSON.stringify(columnWidths),
);
}, [columnWidths]);
const handleColumnResize = (params: GridColumnResizeParams) => { const connRows = useMemo((): ConnectionRow[] => {
const { colDef, width } = params;
console.log("Column resize:", colDef.field, width);
setColumnWidths((prev) => ({
...prev,
[colDef.field]: width,
}));
};
const connRows = useMemo(() => {
return connections.map((each) => { return connections.map((each) => {
const { metadata, rulePayload } = each; const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / "); const chains = [...each.chains].reverse().join(" / ");
@@ -148,11 +82,11 @@ export const ConnectionTable = (props: Props) => {
: `${metadata.remoteDestination}:${metadata.destinationPort}`, : `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download, download: each.download,
upload: each.upload, upload: each.upload,
dlSpeed: each.curDownload, dlSpeed: each.curDownload ?? 0,
ulSpeed: each.curUpload, ulSpeed: each.curUpload ?? 0,
chains, chains,
rule, rule,
process: truncateStr(metadata.process || metadata.processPath), process: truncateStr(metadata.process || metadata.processPath) ?? '',
time: each.start, time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`, source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: Destination, remoteDestination: Destination,
@@ -162,24 +96,97 @@ export const ConnectionTable = (props: Props) => {
}); });
}, [connections]); }, [connections]);
const columns = useMemo<ColumnDef<ConnectionRow>[]>(() => [
{ accessorKey: "host", header: () => t("Host"), size: columnSizing?.host || 220, minSize: 180 },
{ accessorKey: "download", header: () => t("Downloaded"), size: columnSizing?.download || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}</div> },
{ accessorKey: "upload", header: () => t("Uploaded"), size: columnSizing?.upload || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}</div> },
{ accessorKey: "dlSpeed", header: () => t("DL Speed"), size: columnSizing?.dlSpeed || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}/s</div> },
{ accessorKey: "ulSpeed", header: () => t("UL Speed"), size: columnSizing?.ulSpeed || 88, cell: ({ getValue }) => <div className="text-right">{parseTraffic(getValue<number>()).join(" ")}/s</div> },
{ accessorKey: "chains", header: () => t("Chains"), size: columnSizing?.chains || 340, minSize: 180 },
{ accessorKey: "rule", header: () => t("Rule"), size: columnSizing?.rule || 280, minSize: 180 },
{ accessorKey: "process", header: () => t("Process"), size: columnSizing?.process || 220, minSize: 180 },
{ accessorKey: "time", header: () => t("Time"), size: columnSizing?.time || 120, minSize: 100, cell: ({ getValue }) => <div className="text-right">{dayjs(getValue<string>()).fromNow()}</div> },
{ accessorKey: "source", header: () => t("Source"), size: columnSizing?.source || 200, minSize: 130 },
{ accessorKey: "remoteDestination", header: () => t("Destination"), size: columnSizing?.remoteDestination || 200, minSize: 130 },
{ accessorKey: "type", header: () => t("Type"), size: columnSizing?.type || 160, minSize: 100 },
], [columnSizing]);
const table = useReactTable({
data: connRows,
columns,
state: { columnSizing },
onColumnSizingChange: setColumnSizing,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
});
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(() => ({
// Явно типизируем `ref` для каждого компонента
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
<div className="h-full" {...props} ref={ref} />
)),
Table: (props) => (
<Table {...props} className="w-full border-collapse" />
),
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
<TableHeader {...props} ref={ref} />
)),
// Явно типизируем пропсы и `ref` для TableRow
TableRow: React.forwardRef<HTMLTableRowElement, { item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>>(
({ item: row, ...props }, ref) => {
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
// Больше не нужно искать ее по индексу!
return (
<TableRow
{...props}
ref={ref}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onShowDetail(row.original.connectionData)}
/>
);
}),
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => <TableBody {...props} ref={ref} />)
}), []);
return ( return (
<DataGrid <div className="h-full rounded-md border overflow-hidden">
hideFooter {connRows.length > 0 ? (
rows={connRows} <TableVirtuoso
columns={columns} scrollerRef={scrollerRef}
onRowClick={(e) => onShowDetail(e.row.connectionData)} data={table.getRowModel().rows}
density="compact" components={VirtuosoTableComponents}
sx={{ fixedHeaderContent={() => (
border: "none", table.getHeaderGroups().map((headerGroup) => (
"div:focus": { outline: "none !important" }, <TableRow key={headerGroup.id} className="hover:bg-transparent bg-background/95 backdrop-blur">
"& .MuiDataGrid-columnHeader": { {headerGroup.headers.map((header) => (
userSelect: "none", <TableHead key={header.id} style={{ width: header.getSize() }} className="p-2">
}, {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
}} </TableHead>
columnVisibilityModel={columnVisible} ))}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)} </TableRow>
onColumnResize={handleColumnResize} ))
disableColumnMenu={false} )}
/> itemContent={(index, row) => (
<>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ width: cell.column.getSize() }}
className="p-2 whitespace-nowrap"
onClick={() => onShowDetail(row.original.connectionData)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</>
)}
/>
) : (
<div className="flex h-full items-center justify-center">
<p>No results.</p>
</div>
)}
</div>
); );
}; };

View File

@@ -0,0 +1,265 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { cn } from "@root/lib/utils";
// Компоненты и иконки
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ChevronsUpDown, Timer, WholeWord } from 'lucide-react';
// Логика
import { useVerge } from '@/hooks/use-verge';
import { useAppData } from '@/providers/app-data-provider';
import delayManager from '@/services/delay';
import { updateProxy, deleteConnection } from '@/services/api';
// --- Типы и константы ---
const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group';
const STORAGE_KEY_SORT_TYPE = 'clash-verge-proxy-sort-type';
type ProxySortType = 'default' | 'delay' | 'name';
interface IProxyGroup {
name: string;
type: string;
now: string;
all: (string | { name: string })[];
}
// --- Вспомогательная функция для цвета задержки ---
function getDelayBadgeVariant(delayValue: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (delayValue < 0) return 'secondary';
if (delayValue >= 10000) return 'destructive';
if (delayValue >= 500) return 'destructive';
if (delayValue >= 200) return 'outline';
return 'default';
}
// --- Дочерний компонент для элемента списка с "живым" обновлением пинга ---
const ProxySelectItem = ({ proxyName, groupName }: { proxyName: string, groupName: string }) => {
const [delay, setDelay] = useState(() => delayManager.getDelay(proxyName, groupName));
const [isJustUpdated, setIsJustUpdated] = useState(false);
useEffect(() => {
const listener = (newDelay: number) => {
setDelay((currentDelay) => {
if (newDelay >= 0 && newDelay !== currentDelay) {
setIsJustUpdated(true);
setTimeout(() => setIsJustUpdated(false), 600);
}
return newDelay;
});
};
delayManager.setListener(proxyName, groupName, listener);
return () => {
delayManager.removeListener(proxyName, groupName);
};
}, [proxyName, groupName]);
return (
<SelectItem key={proxyName} value={proxyName}>
<div className="flex items-center justify-between w-full">
<span className="truncate">{proxyName}</span>
<Badge
variant={getDelayBadgeVariant(delay)}
className={cn(
"ml-4 flex-shrink-0 px-2 h-5 justify-center transition-colors duration-300",
isJustUpdated && "bg-primary/20 border-primary/50"
)}
>
{delay < 0 ? '---' : delay}
</Badge>
</div>
</SelectItem>
);
};
export const ProxySelectors: React.FC = () => {
const { t } = useTranslation();
const { verge } = useVerge();
const { proxies, connections, clashConfig, refreshProxy } = useAppData();
const mode = clashConfig?.mode?.toLowerCase() || 'rule';
const isGlobalMode = mode === 'global';
const isDirectMode = mode ==='direct';
const [selectedGroup, setSelectedGroup] = useState<string>('');
const [selectedProxy, setSelectedProxy] = useState<string>('');
const [sortType, setSortType] = useState<ProxySortType>(() => (localStorage.getItem(STORAGE_KEY_SORT_TYPE) as ProxySortType) || 'default');
useEffect(() => {
if (!proxies?.groups) return;
if (isGlobalMode) { setSelectedGroup('GLOBAL'); return; }
if (isDirectMode) { setSelectedGroup('DIRECT'); return; }
const savedGroup = localStorage.getItem(STORAGE_KEY_GROUP);
const primaryGroup = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector' && g.name.toLowerCase().includes('auto')) || proxies.groups.find((g: IProxyGroup) => g.type === 'Selector');
if (savedGroup && proxies.groups.some((g: IProxyGroup) => g.name === savedGroup)) {
setSelectedGroup(savedGroup);
} else if (primaryGroup) {
setSelectedGroup(primaryGroup.name);
} else if (proxies.groups.length > 0) {
const firstSelector = proxies.groups.find((g: IProxyGroup) => g.type === 'Selector');
if (firstSelector) {
setSelectedGroup(firstSelector.name);
}
}
}, [proxies, isGlobalMode, isDirectMode]);
useEffect(() => {
if (!selectedGroup || !proxies) return;
if (isGlobalMode) { setSelectedProxy(proxies.global?.now || ''); return; }
if (isDirectMode) { setSelectedProxy('DIRECT'); return; }
const group = proxies.groups.find((g: IProxyGroup) => g.name === selectedGroup);
if (group) {
const current = group.now;
const firstInList = typeof group.all?.[0] === 'string' ? group.all[0] : group.all?.[0]?.name;
setSelectedProxy(current || firstInList || '');
}
}, [selectedGroup, proxies, isGlobalMode, isDirectMode]);
useEffect(() => {
if (!selectedGroup || !proxies?.groups || isGlobalMode || isDirectMode) return;
const group = proxies.groups.find((g: IProxyGroup) => g.name === selectedGroup);
if (group && group.all) {
const proxyNames = group.all.map((p: any) => typeof p === 'string' ? p : p.name).filter(Boolean);
const timeout = verge?.default_latency_timeout || 5000;
delayManager.checkListDelay(proxyNames, selectedGroup, timeout);
}
}, [selectedGroup, proxies, isGlobalMode, isDirectMode, verge]);
const handleGroupChange = (newGroup: string) => {
if (isGlobalMode || isDirectMode) return;
setSelectedGroup(newGroup);
localStorage.setItem(STORAGE_KEY_GROUP, newGroup);
};
const handleProxyChange = async (newProxy: string) => {
if (newProxy === selectedProxy) return;
const previousProxy = selectedProxy;
setSelectedProxy(newProxy);
try {
await updateProxy(selectedGroup, newProxy);
if (verge?.auto_close_connection && previousProxy) {
connections?.data.forEach((conn: any) => {
if (conn.chains.includes(previousProxy)) {
deleteConnection(conn.id);
}
});
}
setTimeout(() => refreshProxy(), 300);
} catch (error) {
console.error("Failed to update proxy", error);
}
};
const handleSortChange = () => {
const nextSort: Record<ProxySortType, ProxySortType> = { default: 'delay', delay: 'name', name: 'default' };
const newSortType = nextSort[sortType];
setSortType(newSortType);
localStorage.setItem(STORAGE_KEY_SORT_TYPE, newSortType);
};
const selectorGroups = useMemo(() => {
if (!proxies?.groups) return [];
return proxies.groups.filter((g: IProxyGroup) => g.type === 'Selector');
}, [proxies]);
const proxyOptions = useMemo(() => {
let options: { name: string }[] = [];
if (isDirectMode) return [{ name: "DIRECT" }];
const sourceList = isGlobalMode ? proxies?.global?.all : proxies?.groups?.find((g: IProxyGroup) => g.name === selectedGroup)?.all;
if (sourceList) {
options = sourceList.map((proxy: any) => ({
name: typeof proxy === 'string' ? proxy : proxy.name,
})).filter((p: { name: string }) => p.name);
}
if (sortType === 'name') return options.sort((a, b) => a.name.localeCompare(b.name));
if (sortType === 'delay') {
return options.sort((a, b) => {
const delayA = delayManager.getDelay(a.name, selectedGroup);
const delayB = delayManager.getDelay(b.name, selectedGroup);
if (delayA < 0) return 1;
if (delayB < 0) return -1;
return delayA - delayB;
});
}
return options;
}, [selectedGroup, proxies, sortType, isGlobalMode, isDirectMode]);
return (
<TooltipProvider>
<div className="flex justify-center flex-col md:flex-row gap-2 md:items-end">
<div className="flex flex-col items-start gap-2">
<label className="text-sm font-medium text-muted-foreground">{t("Group")}</label>
<Select value={selectedGroup} onValueChange={handleGroupChange} disabled={isGlobalMode || isDirectMode}>
<SelectTrigger className="w-48">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<SelectValue placeholder={t("Select a group...")} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{selectedGroup}</p>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent>
{selectorGroups.map((group: IProxyGroup) => (
<SelectItem key={group.name} value={group.name}>{group.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col items-start gap-2">
<div className="flex justify-between items-center w-48">
<label className="text-sm font-medium text-muted-foreground">{t("Proxy")}</label>
<Button variant="ghost" size="sm" onClick={handleSortChange} disabled={isDirectMode}>
{sortType === 'default' && <ChevronsUpDown className="h-4 w-4" />}
{sortType === 'delay' && <Timer className="h-4 w-4" />}
{sortType === 'name' && <WholeWord className="h-4 w-4" />}
</Button>
</div>
<Select value={selectedProxy} onValueChange={handleProxyChange} disabled={isDirectMode}>
<SelectTrigger className="w-48">
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate">
<SelectValue placeholder={t("Select a proxy...")} />
</span>
</TooltipTrigger>
<TooltipContent>
<p>{selectedProxy}</p>
</TooltipContent>
</Tooltip>
</SelectTrigger>
<SelectContent>
{proxyOptions.map(proxy => (
<ProxySelectItem
key={proxy.name}
proxyName={proxy.name}
groupName={selectedGroup}
/>
))}
</SelectContent>
</Select>
</div>
</div>
</TooltipProvider>
);
};

View File

@@ -1,71 +1,43 @@
import { import { Link, useMatch, useResolvedPath } from "react-router-dom";
alpha,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
} from "@mui/material";
import { useMatch, useResolvedPath, useNavigate } from "react-router-dom";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { cn } from "@root/lib/utils";
interface Props { interface Props {
to: string; to: string;
children: string; children: string;
icon: React.ReactNode[]; icon: React.ReactNode[];
} }
export const LayoutItem = (props: Props) => { export const LayoutItem = (props: Props) => {
const { to, children, icon } = props; const { to, children, icon } = props;
const { verge } = useVerge(); const { verge } = useVerge();
const { menu_icon } = verge ?? {}; const { menu_icon } = verge ?? {};
const resolved = useResolvedPath(to); const resolved = useResolvedPath(to);
const match = useMatch({ path: resolved.pathname, end: true }); const match = useMatch({ path: resolved.pathname, end: true });
const navigate = useNavigate();
return ( return (
<ListItem sx={{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" }}> <Link
<ListItemButton to={to}
selected={!!match} className={cn(
sx={[ "flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ match
borderRadius: 2, ? "bg-primary text-primary-foreground shadow-md"
marginLeft: 1.25, : "hover:bg-muted/50",
paddingLeft: 1, "mx-auto my-1 w-[calc(100%-10px)]",
paddingRight: 1, )}
marginRight: 1.25, >
"& .MuiListItemText-primary": { {(menu_icon === "monochrome" || !menu_icon) && (
color: "text.primary", <span className="mr-2 text-foreground">{icon[0]}</span>
fontWeight: "700", )}
}, {menu_icon === "colorful" && <span className="mr-2">{icon[1]}</span>}
}, <span
({ palette: { mode, primary } }) => { className={cn(
const bgcolor = "text-center",
mode === "light" menu_icon === "disable" ? "" : "ml-[-35px]",
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? "#1f1f1f" : "#ffffff";
return {
"&.Mui-selected": { bgcolor },
"&.Mui-selected:hover": { bgcolor },
"&.Mui-selected .MuiListItemText-primary": { color },
};
},
]}
onClick={() => navigate(to)}
>
{(menu_icon === "monochrome" || !menu_icon) && (
<ListItemIcon sx={{ color: "text.primary", marginLeft: "6px" }}>
{icon[0]}
</ListItemIcon>
)} )}
{menu_icon === "colorful" && <ListItemIcon>{icon[1]}</ListItemIcon>} >
<ListItemText {children}
sx={{ </span>
textAlign: "center", </Link>
marginLeft: menu_icon === "disable" ? "" : "-35px",
}}
primary={children}
/>
</ListItemButton>
</ListItem>
); );
}; };

View File

@@ -1,10 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Box, Typography } from "@mui/material"; import { ArrowDown, ArrowUp, Database } from "lucide-react";
import {
ArrowDownwardRounded,
ArrowUpwardRounded,
MemoryRounded,
} from "@mui/icons-material";
import { useClashInfo } from "@/hooks/use-clash"; import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { TrafficGraph, type TrafficRef } from "./traffic-graph"; import { TrafficGraph, type TrafficRef } from "./traffic-graph";
@@ -14,6 +9,7 @@ import useSWRSubscription from "swr/subscription";
import { createAuthSockette } from "@/utils/websocket"; import { createAuthSockette } from "@/utils/websocket";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isDebugEnabled, gc } from "@/services/api"; import { isDebugEnabled, gc } from "@/services/api";
import { cn } from "@root/lib/utils";
interface MemoryUsage { interface MemoryUsage {
inuse: number; inuse: number;
@@ -149,77 +145,77 @@ export const LayoutTraffic = () => {
const [down, downUnit] = parseTraffic(traffic.down); const [down, downUnit] = parseTraffic(traffic.down);
const [inuse, inuseUnit] = parseTraffic(memory.inuse); const [inuse, inuseUnit] = parseTraffic(memory.inuse);
const boxStyle: any = {
display: "flex",
alignItems: "center",
whiteSpace: "nowrap",
};
const iconStyle: any = {
sx: { mr: "8px", fontSize: 16 },
};
const valStyle: any = {
component: "span",
textAlign: "center",
sx: { flex: "1 1 56px", userSelect: "none" },
};
const unitStyle: any = {
component: "span",
color: "grey.500",
fontSize: "12px",
textAlign: "right",
sx: { flex: "0 1 27px", userSelect: "none" },
};
return ( return (
<Box position="relative"> <div className="relative">
{trafficGraph && pageVisible && ( {trafficGraph && pageVisible && (
<div <div
style={{ width: "100%", height: 60, marginBottom: 6 }} className="mb-1.5 h-[60px] w-full"
onClick={trafficRef.current?.toggleStyle} onClick={trafficRef.current?.toggleStyle}
> >
<TrafficGraph ref={trafficRef} /> <TrafficGraph ref={trafficRef} />
</div> </div>
)} )}
<Box display="flex" flexDirection="column" gap={0.75}> <div className="flex flex-col gap-0.5">
<Box title={t("Upload Speed")} {...boxStyle}> <div
<ArrowUpwardRounded title={t("Upload Speed")}
{...iconStyle} className="flex items-center whitespace-nowrap"
color={+up > 0 ? "secondary" : "disabled"} >
<ArrowUp
className={cn(
"mr-2 h-4 w-4",
+up > 0 ? "text-secondary" : "text-muted-foreground",
)}
/> />
<Typography {...valStyle} color="secondary"> <span className="w-[56px] flex-1 select-none text-center text-secondary">
{up} {up}
</Typography> </span>
<Typography {...unitStyle}>{upUnit}/s</Typography> <span className="w-[27px] flex-none select-none text-right text-xs text-muted-foreground">
</Box> {upUnit}/s
</span>
</div>
<Box title={t("Download Speed")} {...boxStyle}> <div
<ArrowDownwardRounded title={t("Download Speed")}
{...iconStyle} className="flex items-center whitespace-nowrap"
color={+down > 0 ? "primary" : "disabled"} >
<ArrowDown
className={cn(
"mr-2 h-4 w-4",
+down > 0 ? "text-primary" : "text-muted-foreground",
)}
/> />
<Typography {...valStyle} color="primary"> <span className="w-[56px] flex-1 select-none text-center text-primary">
{down} {down}
</Typography> </span>
<Typography {...unitStyle}>{downUnit}/s</Typography> <span className="w-[27px] flex-none select-none text-right text-xs text-muted-foreground">
</Box> {downUnit}/s
</span>
</div>
{displayMemory && ( {displayMemory && (
<Box <div
title={t(isDebug ? "Memory Cleanup" : "Memory Usage")} title={t(isDebug ? "Memory Cleanup" : "Memory Usage")}
{...boxStyle} className={cn(
sx={{ cursor: isDebug ? "pointer" : "auto" }} "flex items-center whitespace-nowrap",
color={isDebug ? "success.main" : "disabled"} isDebug
? "cursor-pointer text-green-500"
: "text-muted-foreground",
)}
onClick={async () => { onClick={async () => {
isDebug && (await gc()); isDebug && (await gc());
}} }}
> >
<MemoryRounded {...iconStyle} /> <Database className="mr-2 h-4 w-4" />
<Typography {...valStyle}>{inuse}</Typography> <span className="w-[56px] flex-1 select-none text-center">
<Typography {...unitStyle}>{inuseUnit}</Typography> {inuse}
</Box> </span>
<span className="w-[27px] flex-none select-none text-right text-xs">
{inuseUnit}
</span>
</div>
)} )}
</Box> </div>
</Box> </div>
); );
}; };

View File

@@ -1,37 +1,26 @@
import { IconButton, Fade, SxProps, Theme } from "@mui/material"; import { ArrowUp } from "lucide-react";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import { Button } from "@/components/ui/button";
import { cn } from "@root/lib/utils";
interface Props { interface Props {
onClick: () => void; onClick: () => void;
show: boolean; show: boolean;
sx?: SxProps<Theme>; className?: string;
} }
export const ScrollTopButton = ({ onClick, show, sx }: Props) => { export const ScrollTopButton = ({ onClick, show, className }: Props) => {
return ( return (
<Fade in={show}> <Button
<IconButton variant="outline"
onClick={onClick} size="icon"
sx={{ onClick={onClick}
position: "absolute", className={cn(
bottom: "20px", "absolute bottom-5 right-5 h-10 w-10 rounded-full bg-background/50 backdrop-blur-sm transition-opacity hover:bg-background/75",
right: "20px", show ? "opacity-100" : "opacity-0 pointer-events-none",
backgroundColor: (theme) => className,
theme.palette.mode === "dark" )}
? "rgba(255,255,255,0.1)" >
: "rgba(0,0,0,0.1)", <ArrowUp className="h-5 w-5" />
"&:hover": { </Button>
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.2)"
: "rgba(0,0,0,0.2)",
},
visibility: show ? "visible" : "hidden",
...sx,
}}
>
<KeyboardArrowUpIcon />
</IconButton>
</Fade>
); );
}; };

View File

@@ -1,5 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useTheme } from "@mui/material"; import { useThemeMode } from "@/services/states";
const maxPoint = 30; const maxPoint = 30;
@@ -32,7 +32,7 @@ export const TrafficGraph = forwardRef<TrafficRef>((props, ref) => {
const cacheRef = useRef<TrafficData | null>(null); const cacheRef = useRef<TrafficData | null>(null);
const { palette } = useTheme(); const mode = useThemeMode();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
appendData: (data: TrafficData) => { appendData: (data: TrafficData) => {
@@ -76,10 +76,14 @@ export const TrafficGraph = forwardRef<TrafficRef>((props, ref) => {
if (!context) return; if (!context) return;
const { primary, secondary, divider } = palette; const computedStyle = getComputedStyle(document.documentElement);
const refLineColor = divider || "rgba(0, 0, 0, 0.12)"; const refLineColor =
const upLineColor = secondary.main || "#9c27b0"; `hsl(${computedStyle.getPropertyValue("--border")})` ||
const downLineColor = primary.main || "#5b5c9d"; "rgba(0, 0, 0, 0.12)";
const upLineColor =
`hsl(${computedStyle.getPropertyValue("--secondary")})` || "#9c27b0";
const downLineColor =
`hsl(${computedStyle.getPropertyValue("--primary")})` || "#5b5c9d";
const width = canvas.width; const width = canvas.width;
const height = canvas.height; const height = canvas.height;
@@ -193,7 +197,7 @@ export const TrafficGraph = forwardRef<TrafficRef>((props, ref) => {
return () => { return () => {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
}; };
}, [palette]); }, [mode]);
return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />; return <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />;
}); });

View File

@@ -1,10 +1,10 @@
import useSWR from "swr"; import useSWR from "swr";
import { useRef } from "react"; import { useRef } from "react";
import { Button } from "@mui/material";
import { check } from "@tauri-apps/plugin-updater"; import { check } from "@tauri-apps/plugin-updater";
import { UpdateViewer } from "../setting/mods/update-viewer"; import { UpdateViewer } from "../setting/mods/update-viewer";
import { DialogRef } from "../base"; import { DialogRef } from "../base";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { Button } from "@/components/ui/button";
interface Props { interface Props {
className?: string; className?: string;
@@ -34,9 +34,8 @@ export const UpdateButton = (props: Props) => {
<UpdateViewer ref={viewerRef} /> <UpdateViewer ref={viewerRef} />
<Button <Button
color="error" variant="destructive"
variant="contained" size="sm"
size="small"
className={className} className={className}
onClick={() => viewerRef.current?.open()} onClick={() => viewerRef.current?.open()}
> >

View File

@@ -1,21 +1,21 @@
import { useVerge } from "@/hooks/use-verge"; import { useEffect, useMemo } from "react";
import { defaultDarkTheme, defaultTheme } from "@/pages/_theme"; import { alpha, createTheme, Shadows, Theme as MuiTheme } from "@mui/material";
import { useSetThemeMode, useThemeMode } from "@/services/states";
import { alpha, createTheme, Theme as MuiTheme, Shadows } from "@mui/material";
import {
arSD as arXDataGrid,
enUS as enXDataGrid,
faIR as faXDataGrid,
ruRU as ruXDataGrid,
zhCN as zhXDataGrid,
} from "@mui/x-data-grid/locales";
import { import {
getCurrentWebviewWindow, getCurrentWebviewWindow,
WebviewWindow, WebviewWindow,
} from "@tauri-apps/api/webviewWindow"; } from "@tauri-apps/api/webviewWindow";
import { Theme as TauriOsTheme } from "@tauri-apps/api/window"; import { useSetThemeMode, useThemeMode } from "@/services/states";
import { useEffect, useMemo } from "react"; import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { useVerge } from "@/hooks/use-verge";
import {
zhCN as zhXDataGrid,
enUS as enXDataGrid,
ruRU as ruXDataGrid,
faIR as faXDataGrid,
arSD as arXDataGrid,
} from "@mui/x-data-grid/locales";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Theme as TauriOsTheme } from "@tauri-apps/api/window";
const languagePackMap: Record<string, any> = { const languagePackMap: Record<string, any> = {
zh: { ...zhXDataGrid }, zh: { ...zhXDataGrid },
@@ -39,10 +39,6 @@ export const useCustomTheme = () => {
const mode = useThemeMode(); const mode = useThemeMode();
const setMode = useSetThemeMode(); const setMode = useSetThemeMode();
// 提取用户自定义的背景图URL
const userBackgroundImage = theme_setting?.background_image || "";
const hasUserBackground = !!userBackgroundImage;
useEffect(() => { useEffect(() => {
if (theme_mode === "light" || theme_mode === "dark") { if (theme_mode === "light" || theme_mode === "dark") {
setMode(theme_mode); setMode(theme_mode);
@@ -56,16 +52,19 @@ export const useCustomTheme = () => {
let isMounted = true; let isMounted = true;
appWindow const timerId = setTimeout(() => {
.theme() if (!isMounted) return;
.then((systemTheme) => { appWindow
if (isMounted && systemTheme) { .theme()
setMode(systemTheme); .then((systemTheme) => {
} if (isMounted && systemTheme) {
}) setMode(systemTheme);
.catch((err) => { }
console.error("Failed to get initial system theme:", err); })
}); .catch((err) => {
console.error("Failed to get initial system theme:", err);
});
}, 0);
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => { const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
if (isMounted) { if (isMounted) {
@@ -75,6 +74,7 @@ export const useCustomTheme = () => {
return () => { return () => {
isMounted = false; isMounted = false;
clearTimeout(timerId);
unlistenPromise unlistenPromise
.then((unlistenFn) => { .then((unlistenFn) => {
if (typeof unlistenFn === "function") { if (typeof unlistenFn === "function") {
@@ -131,7 +131,6 @@ export const useCustomTheme = () => {
}, },
background: { background: {
paper: dt.background_color, paper: dt.background_color,
default: dt.background_color,
}, },
}, },
shadows: Array(25).fill("none") as Shadows, shadows: Array(25).fill("none") as Shadows,
@@ -158,10 +157,6 @@ export const useCustomTheme = () => {
warning: { main: dt.warning_color }, warning: { main: dt.warning_color },
success: { main: dt.success_color }, success: { main: dt.success_color },
text: { primary: dt.primary_text, secondary: dt.secondary_text }, text: { primary: dt.primary_text, secondary: dt.secondary_text },
background: {
paper: dt.background_color,
default: dt.background_color,
},
}, },
typography: { fontFamily: dt.font_family }, typography: { fontFamily: dt.font_family },
}); });
@@ -169,10 +164,9 @@ export const useCustomTheme = () => {
const rootEle = document.documentElement; const rootEle = document.documentElement;
if (rootEle) { if (rootEle) {
const backgroundColor = const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
mode === "light" ? "#ECECEC" : dt.background_color; const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
const selectColor = mode === "light" ? "#f5f5f5" : "#3E3E3E"; const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
const scrollColor = mode === "light" ? "#90939980" : "#555555";
const dividerColor = const dividerColor =
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)"; mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
@@ -188,96 +182,16 @@ export const useCustomTheme = () => {
"--background-color-alpha", "--background-color-alpha",
alpha(muiTheme.palette.primary.main, 0.1), alpha(muiTheme.palette.primary.main, 0.1),
); );
// 添加CSS变量
rootEle.style.setProperty(
"--window-border-color",
mode === "light" ? "#cccccc" : "#1E1E1E",
);
rootEle.style.setProperty(
"--scrollbar-bg",
mode === "light" ? "#f1f1f1" : "#2E303D",
);
rootEle.style.setProperty(
"--scrollbar-thumb",
mode === "light" ? "#c1c1c1" : "#555555",
);
// 设置背景图相关变量
rootEle.style.setProperty(
"--user-background-image",
hasUserBackground ? `url('${userBackgroundImage}')` : "none",
);
rootEle.style.setProperty(
"--background-blend-mode",
setting.background_blend_mode || "normal",
);
rootEle.style.setProperty(
"--background-opacity",
setting.background_opacity !== undefined
? String(setting.background_opacity)
: "1",
);
} }
// inject css
let styleElement = document.querySelector("style#verge-theme"); let styleElement = document.querySelector("style#verge-theme");
if (!styleElement) { if (!styleElement) {
styleElement = document.createElement("style"); styleElement = document.createElement("style");
styleElement.id = "verge-theme"; styleElement.id = "verge-theme";
document.head.appendChild(styleElement!); document.head.appendChild(styleElement!);
} }
if (styleElement) { if (styleElement) {
// 改进的全局样式,支持用户自定义背景图 styleElement.innerHTML = setting.css_injection || "";
const globalStyles = `
/* 修复滚动条样式 */
::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: var(--scrollbar-bg);
}
::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: ${mode === "light" ? "#a1a1a1" : "#666666"};
}
/* 背景图处理 */
body {
background-color: var(--background-color);
${
hasUserBackground
? `
background-image: var(--user-background-image);
background-size: cover;
background-position: center;
background-attachment: fixed;
background-blend-mode: var(--background-blend-mode);
opacity: var(--background-opacity);
`
: ""
}
}
/* 修复可能的白色边框 */
.MuiPaper-root {
border-color: var(--window-border-color) !important;
}
/* 确保模态框和对话框也使用暗色主题 */
.MuiDialog-paper {
background-color: ${mode === "light" ? "#ffffff" : "#2E303D"} !important;
}
/* 移除可能的白色点或线条 */
* {
outline: none !important;
box-shadow: none !important;
}
`;
styleElement.innerHTML = (setting.css_injection || "") + globalStyles;
} }
const { palette } = muiTheme; const { palette } = muiTheme;
@@ -293,13 +207,7 @@ export const useCustomTheme = () => {
}, 0); }, 0);
return muiTheme; return muiTheme;
}, [ }, [mode, theme_setting, i18n.language]);
mode,
theme_setting,
i18n.language,
userBackgroundImage,
hasUserBackground,
]);
return { theme }; return { theme };
}; };

View File

@@ -1,45 +1,5 @@
import { styled, Box } from "@mui/material";
import { SearchState } from "@/components/base/base-search-box"; import { SearchState } from "@/components/base/base-search-box";
const Item = styled(Box)(({ theme: { palette, typography } }) => ({
padding: "8px 0",
margin: "0 12px",
lineHeight: 1.35,
borderBottom: `1px solid ${palette.divider}`,
fontSize: "0.875rem",
fontFamily: typography.fontFamily,
userSelect: "text",
"& .time": {
color: palette.text.secondary,
},
"& .type": {
display: "inline-block",
marginLeft: 8,
textAlign: "center",
borderRadius: 2,
textTransform: "uppercase",
fontWeight: "600",
},
'& .type[data-type="error"], & .type[data-type="err"]': {
color: palette.error.main,
},
'& .type[data-type="warning"], & .type[data-type="warn"]': {
color: palette.warning.main,
},
'& .type[data-type="info"], & .type[data-type="inf"]': {
color: palette.info.main,
},
"& .data": {
color: palette.text.primary,
overflowWrap: "anywhere",
},
"& .highlight": {
backgroundColor: palette.mode === "dark" ? "#ffeb3b40" : "#ffeb3b90",
borderRadius: 2,
padding: "0 2px",
},
}));
interface Props { interface Props {
value: ILogItem; value: ILogItem;
searchState?: SearchState; searchState?: SearchState;
@@ -70,7 +30,10 @@ const LogItem = ({ value, searchState }: Props) => {
return parts.map((part, index) => { return parts.map((part, index) => {
return index % 2 === 1 ? ( return index % 2 === 1 ? (
<span key={index} className="highlight"> <span
key={index}
className="highlight bg-yellow-300 dark:bg-yellow-500 bg-opacity-50 dark:bg-opacity-40 rounded px-0.5"
>
{part} {part}
</span> </span>
) : ( ) : (
@@ -82,18 +45,34 @@ const LogItem = ({ value, searchState }: Props) => {
} }
}; };
let typeColorClass = "text-gray-500 dark:text-gray-400";
const lowerCaseType = value.type.toLowerCase();
if (lowerCaseType === "error" || lowerCaseType === "err") {
typeColorClass = "text-red-500 dark:text-red-400";
} else if (lowerCaseType === "warning" || lowerCaseType === "warn") {
typeColorClass = "text-yellow-500 dark:text-yellow-400";
} else if (lowerCaseType === "info" || lowerCaseType === "inf") {
typeColorClass = "text-blue-500 dark:text-blue-400";
}
return ( return (
<Item> <div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 text-sm font-mono select-text">
<div> <div>
<span className="time">{renderHighlightText(value.time || "")}</span> <span className="text-gray-500 dark:text-gray-400 mr-2">
<span className="type" data-type={value.type.toLowerCase()}> {renderHighlightText(value.time || "")}
</span>
<span
className={`inline-block ml-2 text-center rounded uppercase font-semibold ${typeColorClass}`}
data-type={lowerCaseType}
>
{renderHighlightText(value.type)} {renderHighlightText(value.type)}
</span> </span>
</div> </div>
<div> <div className="text-gray-800 dark:text-gray-200 break-all whitespace-pre-wrap">
<span className="data">{renderHighlightText(value.payload)}</span> {renderHighlightText(value.payload)}
</div> </div>
</Item> </div>
); );
}; };

View File

@@ -1,46 +1,48 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Новые импорты из shadcn/ui
import { import {
Button, AlertDialog,
Dialog, AlertDialogAction,
DialogActions, AlertDialogCancel,
DialogContent, AlertDialogContent,
DialogTitle, AlertDialogDescription,
} from "@mui/material"; AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@root/lib/utils";
interface Props { interface Props {
open: boolean; open: boolean;
title: string; title: string;
message: string; description: string;
onClose: () => void; onOpenChange: (open: boolean) => void; // shadcn использует этот коллбэк
onConfirm: () => void; onConfirm: () => void;
} }
export const ConfirmViewer = (props: Props) => { export const ConfirmViewer = (props: Props) => {
const { open, title, message, onClose, onConfirm } = props; const { open, title, description, onOpenChange, onConfirm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
useEffect(() => {
if (!open) return;
}, [open]);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth> <AlertDialog open={open} onOpenChange={onOpenChange}>
<DialogTitle>{title}</DialogTitle> <AlertDialogContent>
<AlertDialogHeader>
<DialogContent sx={{ pb: 1, userSelect: "text" }}> <AlertDialogTitle>{title}</AlertDialogTitle>
{message} <AlertDialogDescription>{description}</AlertDialogDescription>
</DialogContent> </AlertDialogHeader>
<AlertDialogFooter>
<DialogActions> <AlertDialogCancel>{t("Cancel")}</AlertDialogCancel>
<Button onClick={onClose} variant="outlined"> <AlertDialogAction
{t("Cancel")} className={cn(buttonVariants({ variant: "destructive" }))}
</Button> onClick={onConfirm}
<Button onClick={onConfirm} variant="contained"> >
{t("Confirm")} {t("Confirm")}
</Button> </AlertDialogAction>
</DialogActions> </AlertDialogFooter>
</Dialog> </AlertDialogContent>
</AlertDialog>
); );
}; };

View File

@@ -1,20 +1,6 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
Button,
ButtonGroup,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
} from "@mui/material";
import {
FormatPaintRounded,
OpenInFullRounded,
CloseFullscreenRounded,
} from "@mui/icons-material";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
@@ -22,6 +8,7 @@ import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import debounce from "@/utils/debounce"; import debounce from "@/utils/debounce";
// --- Новые импорты ---
import * as monaco from "monaco-editor"; import * as monaco from "monaco-editor";
import MonacoEditor from "react-monaco-editor"; import MonacoEditor from "react-monaco-editor";
import { configureMonacoYaml } from "monaco-yaml"; import { configureMonacoYaml } from "monaco-yaml";
@@ -29,8 +16,26 @@ import { type JSONSchema7 } from "json-schema";
import metaSchema from "meta-json-schema/schemas/meta-json-schema.json"; import metaSchema from "meta-json-schema/schemas/meta-json-schema.json";
import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json"; import mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
import pac from "types-pac/pac.d.ts?raw"; import pac from "types-pac/pac.d.ts?raw";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Wand2, Maximize, Minimize } from "lucide-react";
const appWindow = getCurrentWebviewWindow(); const appWindow = getCurrentWebviewWindow();
// --- Типы и интерфейсы (без изменений) ---
type Language = "yaml" | "javascript" | "css"; type Language = "yaml" | "javascript" | "css";
type Schema<T extends Language> = LanguageSchemaMap[T]; type Schema<T extends Language> = LanguageSchemaMap[T];
interface LanguageSchemaMap { interface LanguageSchemaMap {
@@ -51,11 +56,11 @@ interface Props<T extends Language> {
onClose: () => void; onClose: () => void;
} }
// --- Логика инициализации Monaco (без изменений) ---
let initialized = false; let initialized = false;
const monacoInitialization = () => { const monacoInitialization = () => {
if (initialized) return; if (initialized) return;
// configure yaml worker
configureMonacoYaml(monaco, { configureMonacoYaml(monaco, {
validate: true, validate: true,
enableSchemaRequest: true, enableSchemaRequest: true,
@@ -74,7 +79,6 @@ const monacoInitialization = () => {
}, },
], ],
}); });
// configure PAC definition
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts"); monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
initialized = true; initialized = true;
@@ -170,85 +174,97 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
}, []); }, []);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth> <Dialog open={open} onOpenChange={onClose}>
<DialogTitle>{title}</DialogTitle>
<DialogContent <DialogContent
sx={{ className="h-[95vh] flex flex-col p-0"
width: "auto", style={{ width: "95vw", maxWidth: "95vw" }}
height: "calc(100vh - 185px)",
overflow: "hidden",
}}
> >
<MonacoEditor <DialogHeader className="p-6 pb-2">
language={language} <DialogTitle>{title}</DialogTitle>
theme={themeMode === "light" ? "vs" : "vs-dark"} </DialogHeader>
options={{
tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
minimap: {
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
},
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
readOnly: readOnly, // 只读模式
readOnlyMessage: { value: t("ReadOnlyMessage") }, // 只读模式尝试编辑时的提示信息
renderValidationDecorations: "on", // 只读模式下显示校验信息
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
},
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
editorWillMount={editorWillMount}
editorDidMount={editorDidMount}
onChange={handleChange}
/>
<ButtonGroup <div className="flex-1 min-h-0 relative px-6">
variant="contained" <MonacoEditor
sx={{ position: "absolute", left: "14px", bottom: "8px" }} height="100%"
> language={language}
<IconButton theme={themeMode === "light" ? "vs" : "vs-dark"}
size="medium" options={{
color="inherit" tabSize: 2,
sx={{ display: readOnly ? "none" : "" }} minimap: {
title={t("Format document")} enabled: document.documentElement.clientWidth >= 1500,
onClick={() => },
editorRef.current mouseWheelZoom: true,
?.getAction("editor.action.formatDocument") readOnly: readOnly,
?.run() quickSuggestions: { strings: true, comments: true, other: true },
} padding: { top: 16 },
> fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
<FormatPaintRounded fontSize="inherit" /> getSystem() === "windows" ? ", twemoji mozilla" : ""
</IconButton> }`,
<IconButton fontLigatures: false,
size="medium" smoothScrolling: true,
color="inherit" }}
title={t(isMaximized ? "Minimize" : "Maximize")} editorWillMount={editorWillMount}
onClick={() => appWindow.toggleMaximize().then(editorResize)} editorDidMount={editorDidMount}
> onChange={handleChange}
{isMaximized ? <CloseFullscreenRounded /> : <OpenInFullRounded />} />
</IconButton> <div className="absolute bottom-4 left-8 z-10 flex gap-2">
</ButtonGroup> <TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
disabled={readOnly}
onClick={() =>
editorRef.current
?.getAction("editor.action.formatDocument")
?.run()
}
>
<Wand2 className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Format document")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
onClick={() =>
appWindow.toggleMaximize().then(editorResize)
}
>
{isMaximized ? (
<Minimize className="h-5 w-5" />
) : (
<Maximize className="h-5 w-5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t(isMaximized ? "Minimize" : "Maximize")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<DialogFooter className="p-6 pt-4">
<DialogClose asChild>
<Button type="button" variant="outline">
{t(readOnly ? "Close" : "Cancel")}
</Button>
</DialogClose>
{!readOnly && (
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
)}
</DialogFooter>
</DialogContent> </DialogContent>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t(readOnly ? "Close" : "Cancel")}
</Button>
{!readOnly && (
<Button onClick={handleSave} variant="contained">
{t("Save")}
</Button>
)}
</DialogActions>
</Dialog> </Dialog>
); );
}; };

View File

@@ -1,61 +1,81 @@
import { useRef, useState } from "react"; import React, { useRef, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Box, Button, Typography } from "@mui/material";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; // Используем Input для консистентности
import { Loader2 } from "lucide-react"; // Иконка для спиннера
interface Props { interface Props {
onChange: (file: File, value: string) => void; onChange: (file: File, value: string) => void;
} }
export const FileInput = (props: Props) => { export const FileInput: React.FC<Props> = (props) => {
const { onChange } = props; const { onChange } = props;
const { t } = useTranslation(); const { t } = useTranslation();
// file input
const inputRef = useRef<any>(undefined); const inputRef = useRef<HTMLInputElement>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fileName, setFileName] = useState(""); const [fileName, setFileName] = useState("");
const onFileInput = useLockFn(async (e: any) => { // Вся ваша логика для чтения файла остается без изменений
const file = e.target.files?.[0] as File; const onFileInput = useLockFn(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setFileName(file.name); setFileName(file.name);
setLoading(true); setLoading(true);
return new Promise((resolve, reject) => { try {
const reader = new FileReader(); const value = await new Promise<string>((resolve, reject) => {
reader.onload = (event) => { const reader = new FileReader();
resolve(null); reader.onload = (event) => {
onChange(file, event.target?.result as string); resolve(event.target?.result as string);
}; };
reader.onerror = reject; reader.onerror = (err) => reject(err);
reader.readAsText(file); reader.readAsText(file);
}).finally(() => setLoading(false)); });
onChange(file, value);
} catch (error) {
console.error("File reading error:", error);
} finally {
setLoading(false);
// Очищаем value у input, чтобы можно было выбрать тот же файл еще раз
if (inputRef.current) {
inputRef.current.value = "";
}
}
}); });
return ( return (
<Box sx={{ mt: 2, mb: 1, display: "flex", alignItems: "center" }}> // Заменяем Box на div с flex и gap для отступов
<div className="flex items-center gap-4 my-4">
<Button <Button
variant="outlined" type="button" // Явно указываем тип, чтобы избежать отправки формы
sx={{ flex: "none" }} variant="outline"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
disabled={loading}
> >
{t("Choose File")} {t("Choose File")}
</Button> </Button>
<input {/* Сам input остается скрытым */}
<Input
type="file" type="file"
accept=".yaml,.yml" accept=".yaml,.yml"
ref={inputRef} ref={inputRef}
style={{ display: "none" }} className="hidden"
onChange={onFileInput} onChange={onFileInput}
/> />
<Typography noWrap sx={{ ml: 1 }}> {/* Область для отображения имени файла или статуса загрузки */}
{loading ? "Loading..." : fileName} <div className="flex min-w-0 items-center gap-2 text-sm text-muted-foreground">
</Typography> {loading && <Loader2 className="h-4 w-4 animate-spin" />}
</Box> <p className="truncate" title={fileName}>
{loading ? t("Loading...") : fileName || t("No file selected")}
</p>
</div>
</div>
); );
}; };

View File

@@ -1,26 +1,34 @@
import { import { useEffect, useState } from "react";
Box,
IconButton,
ListItem,
ListItemText,
alpha,
styled,
} from "@mui/material";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { downloadIconCache } from "@/services/cmds"; import { downloadIconCache } from "@/services/cmds";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useState } from "react"; import { cn } from "@root/lib/utils";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { GripVertical, Trash2, Undo2 } from "lucide-react";
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: "prepend" | "original" | "delete" | "append";
group: IProxyGroupConfig; group: IProxyGroupConfig;
onDelete: () => void; onDelete: () => void;
} }
// Определяем стили для каждого типа элемента
const typeStyles = {
original: "bg-secondary/50",
delete: "bg-destructive/20 text-muted-foreground line-through",
prepend: "bg-green-500/20",
append: "bg-green-500/20",
};
export const GroupItem = (props: Props) => { export const GroupItem = (props: Props) => {
let { type, group, onDelete } = props; const { type, group, onDelete } = props;
const sortable = type === "prepend" || type === "append";
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
const { const {
attributes, attributes,
@@ -29,145 +37,73 @@ export const GroupItem = (props: Props) => {
transform, transform,
transition, transition,
isDragging, isDragging,
} = sortable } = useSortable({ id: group.name, disabled: !isSortable });
? useSortable({ id: group.name })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
const [iconCachePath, setIconCachePath] = useState(""); const [iconCachePath, setIconCachePath] = useState("");
useEffect(() => { const getFileName = (url: string) => url.substring(url.lastIndexOf("/") + 1);
initIconCachePath();
}, [group]);
async function initIconCachePath() { async function initIconCachePath() {
if (group.icon && group.icon.trim().startsWith("http")) { if (group.icon && group.icon.trim().startsWith("http")) {
const fileName = const fileName = group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
const iconPath = await downloadIconCache(group.icon, fileName); const iconPath = await downloadIconCache(group.icon, fileName);
setIconCachePath(convertFileSrc(iconPath)); setIconCachePath(convertFileSrc(iconPath));
} }
} }
function getFileName(url: string) { useEffect(() => { initIconCachePath(); }, [group.icon, group.name]);
return url.substring(url.lastIndexOf("/") + 1);
} const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 100 : undefined,
};
return ( return (
<ListItem <div
dense ref={setNodeRef}
sx={({ palette }) => ({ style={style}
position: "relative", // Применяем стили в зависимости от типа
background: className={cn(
type === "original" "flex items-center p-2 mb-1 rounded-lg transition-shadow",
? palette.mode === "dark" typeStyles[type],
? alpha(palette.background.paper, 0.3) isDragging && "shadow-lg"
: alpha(palette.grey[400], 0.3) )}
: type === "delete"
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
> >
{group.icon && group.icon?.trim().startsWith("http") && ( {/* Ручка для перетаскивания */}
<img <div
src={iconCachePath === "" ? group.icon : iconCachePath}
width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
}}
/>
)}
{group.icon && group.icon?.trim().startsWith("data") && (
<img
src={group.icon}
width="32px"
style={{
marginRight: "12px",
borderRadius: "6px",
}}
/>
)}
{group.icon && group.icon?.trim().startsWith("<svg") && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon ?? "")}`}
width="32px"
/>
)}
<ListItemText
{...attributes} {...attributes}
{...listeners} {...listeners}
ref={setNodeRef} className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
sx={{ cursor: sortable ? "move" : "" }} >
primary={ <GripVertical className="h-5 w-5" />
<StyledPrimary </div>
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
> {/* Иконка группы */}
{group.name} {group.icon && (
</StyledPrimary> <img
} src={group.icon.startsWith('data') ? group.icon : group.icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}` : (iconCachePath || group.icon)}
secondary={ className="w-8 h-8 mx-2 rounded-md"
<ListItemTextChild alt={group.name}
sx={{ />
overflow: "hidden", )}
display: "flex",
alignItems: "center", {/* Название и тип группы */}
pt: "2px", <div className="flex-1 min-w-0">
}} <p className="text-sm font-semibold truncate">{group.name}</p>
> <div className="flex items-center text-xs text-muted-foreground mt-1">
<Box sx={{ marginTop: "2px" }}> <Badge variant="outline">{group.type}</Badge>
<StyledTypeBox>{group.type}</StyledTypeBox> </div>
</Box> </div>
</ListItemTextChild>
} {/* Кнопка действия */}
secondaryTypographyProps={{ <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
sx: { {type === "delete" ? (
display: "flex", <Undo2 className="h-4 w-4" />
alignItems: "center", ) : (
color: "#ccc", <Trash2 className="h-4 w-4 text-destructive" />
}, )}
}} </Button>
/> </div>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
); );
}; };
const StyledPrimary = styled("div")`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ListItemTextChild = styled("span")`
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
lineHeight: 1.5,
marginRight: "8px",
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,19 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Новые импорты
import { import {
Button,
Chip,
Dialog, Dialog,
DialogActions,
DialogContent, DialogContent,
DialogHeader,
DialogTitle, DialogTitle,
Divider, DialogFooter,
Typography, DialogClose,
} from "@mui/material"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { BaseEmpty } from "@/components/base"; import { BaseEmpty } from "@/components/base";
import { cn } from "@root/lib/utils";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -20,50 +23,47 @@ interface Props {
export const LogViewer = (props: Props) => { export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props; const { open, logInfo, onClose } = props;
const { t } = useTranslation(); const { t } = useTranslation();
// Вспомогательная функция для определения варианта Badge
const getLogLevelVariant = (level: string): "destructive" | "secondary" => {
return level === "error" || level === "exception" ? "destructive" : "secondary";
};
return ( return (
<Dialog open={open} onClose={onClose}> <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogTitle>{t("Script Console")}</DialogTitle> <DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("Script Console")}</DialogTitle>
</DialogHeader>
<DialogContent {/* Контейнер для логов с прокруткой */}
sx={{ <div className="h-[300px] overflow-y-auto space-y-2 p-1">
width: 400, {logInfo.length > 0 ? (
height: 300, logInfo.map(([level, log], index) => (
overflowX: "hidden", <div key={index} className="pb-2 border-b border-border last:border-b-0">
userSelect: "text", <div className="flex items-start gap-3">
pb: 1, <Badge variant={getLogLevelVariant(level)} className="mt-0.5">
}} {level}
> </Badge>
{logInfo.map(([level, log], index) => ( {/* `whitespace-pre-wrap` сохраняет переносы строк и пробелы в логах */}
<Fragment key={index.toString()}> <p className="flex-1 text-sm whitespace-pre-wrap break-words font-mono">
<Typography color="text.secondary" component="div"> {log}
<Chip </p>
label={level} </div>
size="small" </div>
variant="outlined" ))
color={ ) : (
level === "error" || level === "exception" <BaseEmpty />
? "error" )}
: "default" </div>
}
sx={{ mr: 1 }}
/>
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
{logInfo.length === 0 && <BaseEmpty />} <DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent> </DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
</Dialog> </Dialog>
); );
}; };

View File

@@ -1,58 +1,41 @@
import { alpha, Box, styled } from "@mui/material"; import * as React from "react";
import { cn } from "@root/lib/utils";
export const ProfileBox = styled(Box)(({ // Определяем пропсы: принимает все атрибуты для div и булевый пропс `selected`
theme, export interface ProfileBoxProps extends React.HTMLAttributes<HTMLDivElement> {
"aria-selected": selected, selected?: boolean;
}) => { }
const { mode, primary, text } = theme.palette;
const key = `${mode}-${!!selected}`;
const backgroundColor = mode === "light" ? "#ffffff" : "#282A36"; export const ProfileBox = React.forwardRef<HTMLDivElement, ProfileBoxProps>(
({ className, selected, children, ...props }, ref) => {
return (
<div
ref={ref}
// Устанавливаем data-атрибут для стилизации выбранного состояния
data-selected={selected}
className={cn(
// --- Базовые стили ---
"relative block w-full cursor-pointer rounded-lg bg-card p-4 text-left text-muted-foreground transition-all duration-200",
const color = { // --- Эффект рамки ---
"light-true": text.secondary, // По умолчанию рамка есть, но она прозрачная, чтобы резервировать место
"light-false": text.secondary, "border-l-4 border-transparent",
"dark-true": alpha(text.secondary, 0.65), // При выборе (`data-selected=true`) рамка окрашивается в основной цвет
"dark-false": alpha(text.secondary, 0.65), "data-[selected=true]:border-primary",
}[key]!;
const h2color = { // --- Эффект смены цвета текста ---
"light-true": primary.main, // При выборе весь текст внутри становится более контрастным
"light-false": text.primary, "data-[selected=true]:text-card-foreground",
"dark-true": primary.main,
"dark-false": text.primary,
}[key]!;
const borderSelect = { // --- Дополнительные классы от пользователя ---
"light-true": { className
borderLeft: `3px solid ${primary.main}`, )}
width: `calc(100% + 3px)`, {...props}
marginLeft: `-3px`, >
}, {children}
"light-false": { </div>
width: "100%", );
}, }
"dark-true": { );
borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
},
"dark-false": {
width: "100%",
},
}[key];
return { ProfileBox.displayName = "ProfileBox";
position: "relative",
display: "block",
cursor: "pointer",
textAlign: "left",
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
...borderSelect,
borderRadius: "8px",
color,
"& h2": { color: h2color },
};
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { import { UnlistenFn } from "@tauri-apps/api/event";
Box,
Badge,
Chip,
Typography,
MenuItem,
Menu,
IconButton,
} from "@mui/material";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds"; import { viewProfile, readProfileFile, saveProfileFile } from "@/services/cmds";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { ProfileBox } from "./profile-box";
import { LogViewer } from "./log-viewer";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { ProfileBox } from "./profile-box"; // Наш рефакторенный компонент
import { LogViewer } from "./log-viewer"; // Наш рефакторенный компонент
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ScrollText, FileText, FolderOpen } from "lucide-react";
interface Props { interface Props {
logInfo?: [string, string][]; logInfo?: [string, string][];
@@ -23,23 +26,18 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void; onSave?: (prev?: string, curr?: string) => void;
} }
// profile enhanced item
export const ProfileMore = (props: Props) => { export const ProfileMore = (props: Props) => {
const { id, logInfo = [], onSave } = props; const { id, logInfo = [], onSave } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [fileOpen, setFileOpen] = useState(false); const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false);
const onEditFile = () => { const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true); setFileOpen(true);
}; };
const onOpenFile = useLockFn(async () => { const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try { try {
await viewProfile(id); await viewProfile(id);
} catch (err: any) { } catch (err: any) {
@@ -47,126 +45,72 @@ export const ProfileMore = (props: Props) => {
} }
}); });
const fnWrapper = (fn: () => void) => () => {
setAnchorEl(null);
return fn();
};
const hasError = !!logInfo.find((e) => e[0] === "exception"); const hasError = !!logInfo.find((e) => e[0] === "exception");
const itemMenu = [ const menuItems = [
{ label: "Edit File", handler: onEditFile }, { label: "Edit File", handler: onEditFile, icon: FileText },
{ label: "Open File", handler: onOpenFile }, { label: "Open File", handler: onOpenFile, icon: FolderOpen },
]; ];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
lineHeight: 1,
};
return ( return (
<> <>
<ProfileBox <ContextMenu>
onDoubleClick={onEditFile} <ContextMenuTrigger>
onContextMenu={(event) => { {/* Используем наш готовый ProfileBox */}
const { clientX, clientY } = event; <ProfileBox onDoubleClick={onEditFile}>
setPosition({ top: clientY, left: clientX }); {/* Верхняя строка: Название и Бейдж */}
setAnchorEl(event.currentTarget); <div className="flex justify-between items-center mb-2">
event.preventDefault(); <p className="font-semibold text-base truncate">{t(`Global ${id}`)}</p>
}} <Badge variant="secondary">{id}</Badge>
> </div>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mb={0.5}
>
<Typography
width="calc(100% - 52px)"
variant="h6"
component="h2"
noWrap
title={t(`Global ${id}`)}
>
{t(`Global ${id}`)}
</Typography>
<Chip {/* Нижняя строка: Кнопка логов или заглушка для сохранения высоты */}
label={id} <div className="h-7 flex items-center">
color="primary" {id === "Script" && (
size="small" <TooltipProvider>
variant="outlined" <Tooltip>
sx={{ height: 20, textTransform: "capitalize" }} <TooltipTrigger asChild>
/> {/* Контейнер для позиционирования точки-индикатора */}
</Box> <div className="relative">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setLogOpen(true)}
>
<ScrollText className="h-4 w-4" />
</Button>
{/* Точка-индикатор ошибки с анимацией */}
{hasError && (
<span className="absolute top-0 right-0 flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-destructive opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-destructive"></span>
</span>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t("Script Console")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</ProfileBox>
</ContextMenuTrigger>
<Box sx={boxStyle}> {/* Содержимое контекстного меню */}
{id === "Script" && <ContextMenuContent>
(hasError ? ( {menuItems.map((item) => (
<Badge color="error" variant="dot" overlap="circular"> <ContextMenuItem key={item.label} onSelect={item.handler}>
<IconButton <item.icon className="mr-2 h-4 w-4" />
size="small" <span>{t(item.label)}</span>
edge="start" </ContextMenuItem>
color="error"
title={t("Script Console")}
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
</Badge>
) : (
<IconButton
size="small"
edge="start"
color="inherit"
title={t("Script Console")}
onClick={() => setLogOpen(true)}
>
<FeaturedPlayListRounded fontSize="inherit" />
</IconButton>
))}
</Box>
</ProfileBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{itemMenu
.filter((item: any) => item.show !== false)
.map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={[
{ minWidth: 120 },
(theme) => {
return {
color:
item.label === "Delete"
? theme.palette.error.main
: undefined,
};
},
]}
dense
>
{t(item.label)}
</MenuItem>
))} ))}
</Menu> </ContextMenuContent>
</ContextMenu>
{/* Модальные окна, которые мы уже переделали */}
{fileOpen && ( {fileOpen && (
<EditorViewer <EditorViewer
open={true} open={true}
@@ -176,7 +120,7 @@ export const ProfileMore = (props: Props) => {
schema={id === "Merge" ? "clash" : undefined} schema={id === "Merge" ? "clash" : undefined}
onSave={async (prev, curr) => { onSave={async (prev, curr) => {
await saveProfileFile(id, curr ?? ""); await saveProfileFile(id, curr ?? "");
onSave && onSave(prev, curr); onSave?.(prev, curr);
}} }}
onClose={() => setFileOpen(false)} onClose={() => setFileOpen(false)}
/> />

View File

@@ -1,29 +1,42 @@
import { import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form"; import { useForm } from "react-hook-form";
import {
Box,
FormControl,
InputAdornment,
InputLabel,
MenuItem,
Select,
styled,
TextField,
} from "@mui/material";
import { createProfile, patchProfile } from "@/services/cmds"; import { createProfile, patchProfile } from "@/services/cmds";
import { BaseDialog, Switch } from "@/components/base";
import { version } from "@root/package.json";
import { FileInput } from "./file-input";
import { useProfiles } from "@/hooks/use-profiles"; import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
// --- Новые импорты ---
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface Props { interface Props {
onChange: (isActivating?: boolean) => void; onChange: (isActivating?: boolean) => void;
@@ -34,361 +47,253 @@ export interface ProfileViewerRef {
edit: (item: IProfileItem) => void; edit: (item: IProfileItem) => void;
} }
// create or edit the profile export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) => {
// remote / local const { t } = useTranslation();
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>( const [open, setOpen] = useState(false);
(props, ref) => { const [openType, setOpenType] = useState<"new" | "edit">("new");
const { t } = useTranslation(); const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false); const { profiles } = useProfiles();
const [openType, setOpenType] = useState<"new" | "edit">("new"); const fileDataRef = useRef<string | null>(null);
const [loading, setLoading] = useState(false);
const { profiles } = useProfiles();
// file input const form = useForm<IProfileItem>({
const fileDataRef = useRef<string | null>(null); defaultValues: {
type: "remote",
const { control, watch, register, ...formIns } = useForm<IProfileItem>({ name: "",
defaultValues: { desc: "",
type: "remote", url: "",
name: "", option: {
desc: "", with_proxy: false,
url: "", self_proxy: false,
option: { danger_accept_invalid_certs: false,
with_proxy: false,
self_proxy: false,
},
}, },
}); },
});
useImperativeHandle(ref, () => ({ const { control, watch, handleSubmit, reset, setValue } = form;
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit");
setOpen(true);
},
}));
const selfProxy = watch("option.self_proxy"); useImperativeHandle(ref, () => ({
const withProxy = watch("option.with_proxy"); create: () => {
reset({ type: "remote", name: "", desc: "", url: "", option: { with_proxy: false, self_proxy: false, danger_accept_invalid_certs: false } });
fileDataRef.current = null;
setOpenType("new");
setOpen(true);
},
edit: (item) => {
reset(item);
fileDataRef.current = null;
setOpenType("edit");
setOpen(true);
},
}));
useEffect(() => { const selfProxy = watch("option.self_proxy");
if (selfProxy) formIns.setValue("option.with_proxy", false); const withProxy = watch("option.with_proxy");
}, [selfProxy]);
useEffect(() => { useEffect(() => {
if (withProxy) formIns.setValue("option.self_proxy", false); if (selfProxy) setValue("option.with_proxy", false);
}, [withProxy]); }, [selfProxy, setValue]);
const handleOk = useLockFn( useEffect(() => {
formIns.handleSubmit(async (form) => { if (withProxy) setValue("option.self_proxy", false);
if (form.option?.timeout_seconds) { }, [withProxy, setValue]);
form.option.timeout_seconds = +form.option.timeout_seconds;
const handleOk = useLockFn(
handleSubmit(async (form) => {
if (form.option?.timeout_seconds) {
form.option.timeout_seconds = +form.option.timeout_seconds;
}
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url) {
throw new Error("The URL should not be null");
} }
setLoading(true); if (form.option?.update_interval) {
try { form.option.update_interval = +form.option.update_interval;
// 基本验证 } else {
if (!form.type) throw new Error("`Type` should not be null"); delete form.option?.update_interval;
if (form.type === "remote" && !form.url) { }
throw new Error("The URL should not be null"); if (form.option?.user_agent === "") {
} delete form.option.user_agent;
}
// 处理表单数据 const name = form.name || `${form.type} file`;
if (form.option?.update_interval) { const item = { ...form, name };
form.option.update_interval = +form.option.update_interval; const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
const isActivating = isUpdate && form.uid === (profiles?.current ?? "");
const originalOptions = { with_proxy: form.option?.with_proxy, self_proxy: form.option?.self_proxy };
if (!isRemote) {
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else { } else {
delete form.option?.update_interval; if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
} }
if (form.option?.user_agent === "") { } else {
delete form.option.user_agent; try {
}
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
// 判断是否是当前激活的配置
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
// 保存原始代理设置以便回退成功后恢复
const originalOptions = {
with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy,
};
// 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) {
if (openType === "new") { if (openType === "new") {
await createProfile(item, fileDataRef.current); await createProfile(item, fileDataRef.current);
} else { } else {
if (!form.uid) throw new Error("UID not found"); if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item); await patchProfile(form.uid, item);
} }
} else { } catch (err) {
// 远程配置使用回退机制 showNotice("info", t("Profile creation failed, retrying with Clash proxy..."));
try { const retryItem = { ...item, option: { ...item.option, with_proxy: false, self_proxy: true } };
// 尝试正常操作 if (openType === "new") {
if (openType === "new") { await createProfile(retryItem, fileDataRef.current);
await createProfile(item, fileDataRef.current); } else {
} else { if (!form.uid) throw new Error("UID not found");
if (!form.uid) throw new Error("UID not found"); await patchProfile(form.uid, retryItem);
await patchProfile(form.uid, item); await patchProfile(form.uid, { option: originalOptions });
}
} catch (err) {
// 首次创建/更新失败,尝试使用自身代理
showNotice(
"info",
t("Profile creation failed, retrying with Clash proxy..."),
);
// 使用自身代理的配置
const retryItem = {
...item,
option: {
...item.option,
with_proxy: false,
self_proxy: true,
},
};
// 使用自身代理再次尝试
if (openType === "new") {
await createProfile(retryItem, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, retryItem);
// 编辑模式下恢复原始代理设置
await patchProfile(form.uid, { option: originalOptions });
}
showNotice(
"success",
t("Profile creation succeeded with Clash proxy"),
);
} }
showNotice("success", t("Profile creation succeeded with Clash proxy"));
} }
// 成功后的操作
setOpen(false);
setTimeout(() => formIns.reset(), 500);
fileDataRef.current = null;
// 优化UI先关闭异步通知父组件
setTimeout(() => {
props.onChange(isActivating);
}, 0);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
} }
}),
);
const handleClose = () => {
try {
setOpen(false); setOpen(false);
fileDataRef.current = null; props.onChange(isActivating);
setTimeout(() => formIns.reset(), 500); } catch (err: any) {
} catch {} showNotice("error", err.message || err.toString());
}; } finally {
setLoading(false);
}
}),
);
const text = { const formType = watch("type");
fullWidth: true, const isRemote = formType === "remote";
size: "small", const isLocal = formType === "local";
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
const formType = watch("type"); return (
const isRemote = formType === "remote"; <Dialog open={open} onOpenChange={setOpen}>
const isLocal = formType === "local"; <DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
</DialogHeader>
return ( <Form {...form}>
<BaseDialog <form onSubmit={e => { e.preventDefault(); handleOk(); }} className="space-y-4 max-h-[70vh] overflow-y-auto px-1">
open={open} <FormField control={control} name="type" render={({ field }) => (
title={openType === "new" ? t("Create Profile") : t("Edit Profile")} <FormItem>
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }} <FormLabel>{t("Type")}</FormLabel>
okBtn={t("Save")} <Select onValueChange={field.onChange} defaultValue={field.value}>
cancelBtn={t("Cancel")} <FormControl><SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger></FormControl>
onClose={handleClose} <SelectContent>
onCancel={handleClose} <SelectItem value="remote">Remote</SelectItem>
onOk={handleOk} <SelectItem value="local">Local</SelectItem>
loading={loading} </SelectContent>
> </Select>
<Controller <FormMessage />
name="type" </FormItem>
control={control} )}/>
render={({ field }) => (
<FormControl size="small" fullWidth sx={{ mt: 1, mb: 1 }}>
<InputLabel>{t("Type")}</InputLabel>
<Select {...field} autoFocus label={t("Type")}>
<MenuItem value="remote">Remote</MenuItem>
<MenuItem value="local">Local</MenuItem>
</Select>
</FormControl>
)}
/>
<Controller <FormField control={control} name="name" render={({ field }) => (
name="name" <FormItem>
control={control} <FormLabel>{t("Name")}</FormLabel>
render={({ field }) => ( <FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl>
<TextField {...text} {...field} label={t("Name")} /> <FormMessage />
)} </FormItem>
/> )}/>
<Controller <FormField control={control} name="desc" render={({ field }) => (
name="desc" <FormItem>
control={control} <FormLabel>{t("Descriptions")}</FormLabel>
render={({ field }) => ( <FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl>
<TextField {...text} {...field} label={t("Descriptions")} /> <FormMessage />
)} </FormItem>
/> )}/>
{isRemote && ( {isRemote && (
<> <>
<Controller <FormField control={control} name="url" render={({ field }) => (
name="url" <FormItem>
control={control} <FormLabel>{t("Subscription URL")}</FormLabel>
render={({ field }) => ( <FormControl><Textarea placeholder="https://example.com/profile.yaml" {...field} /></FormControl>
<TextField <FormMessage />
{...text} </FormItem>
{...field} )}/>
multiline <FormField control={control} name="option.user_agent" render={({ field }) => (
label={t("Subscription URL")} <FormItem>
/> <FormLabel>User Agent</FormLabel>
)} <FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl>
/> <FormMessage />
</FormItem>
<Controller )}/>
name="option.user_agent" <FormField control={control} name="option.update_interval" render={({ field }) => (
control={control} <FormItem>
render={({ field }) => ( <FormLabel>{t("Update Interval")}</FormLabel>
<TextField <FormControl>
{...text} <div className="flex items-center gap-2">
{...field} <Input type="number" placeholder="1440" {...field} onChange={event => field.onChange(parseInt(event.target.value, 10) || 0)} />
placeholder={`clash-verge/v${version}`} <span className="text-sm text-muted-foreground">{t("mins")}</span>
label="User Agent" </div>
/> </FormControl>
)} <FormMessage />
/> </FormItem>
)}/>
<Controller </>
name="option.timeout_seconds"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
type="number"
placeholder="60"
label={t("HTTP Request Timeout")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("seconds")}
</InputAdornment>
),
},
}}
/>
)}
/>
</>
)}
{(isRemote || isLocal) && (
<Controller
name="option.update_interval"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
type="number"
label={t("Update Interval")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("mins")}
</InputAdornment>
),
},
}}
/>
)} )}
/>
)}
{isLocal && openType === "new" && ( {isLocal && openType === "new" && (
<FileInput <FormItem>
onChange={(file, val) => { <FormLabel>{t("File")}</FormLabel>
formIns.setValue("name", formIns.getValues("name") || file.name); <FormControl>
fileDataRef.current = val; <Input type="file" accept=".yml,.yaml" onChange={(e) => {
}} const file = e.target.files?.[0];
/> if (file) {
)} setValue("name", form.getValues("name") || file.name);
const reader = new FileReader();
reader.onload = (event) => {
fileDataRef.current = event.target?.result as string;
};
reader.readAsText(file);
}
}} />
</FormControl>
<FormMessage />
</FormItem>
)}
{isRemote && ( {isRemote && (
<> <div className="space-y-4 rounded-md border p-4">
<Controller <FormField control={control} name="option.with_proxy" render={({ field }) => (
name="option.with_proxy" <FormItem className="flex flex-row items-center justify-between">
control={control} <FormLabel>{t("Use System Proxy")}</FormLabel>
render={({ field }) => ( <FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
<StyledBox> </FormItem>
<InputLabel>{t("Use System Proxy")}</InputLabel> )}/>
<Switch checked={field.value} {...field} color="primary" /> <FormField control={control} name="option.self_proxy" render={({ field }) => (
</StyledBox> <FormItem className="flex flex-row items-center justify-between">
)} <FormLabel>{t("Use Clash Proxy")}</FormLabel>
/> <FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
)}/>
<FormField control={control} name="option.danger_accept_invalid_certs" render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormLabel className="text-destructive">{t("Accept Invalid Certs (Danger)")}</FormLabel>
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
)}/>
</div>
)}
<Controller <button type="submit" className="hidden" />
name="option.self_proxy" </form>
control={control} </Form>
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Use Clash Proxy")}</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
)}
/>
<Controller <DialogFooter>
name="option.danger_accept_invalid_certs" <DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
control={control} <Button type="button" onClick={handleOk} disabled={loading}>
render={({ field }) => ( {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<StyledBox> {t("Save")}
<InputLabel>{t("Accept Invalid Certs (Danger)")}</InputLabel> </Button>
<Switch checked={field.value} {...field} color="primary" /> </DialogFooter>
</StyledBox> </DialogContent>
)} </Dialog>
/> );
</> });
)}
</BaseDialog>
);
},
);
const StyledBox = styled(Box)(() => ({
margin: "8px 0 8px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}));

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react"; import { ReactNode, useEffect, useMemo, useState, forwardRef } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import yaml from "js-yaml"; import yaml from "js-yaml";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,33 +14,41 @@ import {
import { import {
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { import { CSS } from "@dnd-kit/utilities";
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
TextField,
styled,
} from "@mui/material";
import {
VerticalAlignTopRounded,
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { ProxyItem } from "@/components/profile/proxy-item";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { BaseSearchBox } from "../base/base-search-box";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor"; import MonacoEditor from "react-monaco-editor";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { useThemeMode } from "@/services/states"; import { useThemeMode } from "@/services/states";
import parseUri from "@/utils/uri-parser"; import parseUri from "@/utils/uri-parser";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// Компоненты
import { BaseSearchBox } from "../base/base-search-box";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
// Иконки
import {
GripVertical,
Trash2,
Undo2,
ArrowDownToLine,
ArrowUpToLine,
} from "lucide-react";
interface Props { interface Props {
profileUid: string; profileUid: string;
property: string; property: string;
@@ -49,6 +57,69 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void; onSave?: (prev?: string, curr?: string) => void;
} }
// Новый, легковесный компонент для элемента списка, с поддержкой drag-and-drop
const EditorProxyItem = ({
p_type,
proxy,
onDelete,
id,
}: {
p_type: string;
proxy: IProxyConfig;
onDelete: () => void;
id: string;
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 100 : undefined,
};
const isDelete = p_type === "delete";
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center p-2 mb-1 rounded-md bg-secondary"
{...attributes}
>
<div
{...listeners}
className="cursor-grab p-1 text-muted-foreground hover:bg-accent rounded-sm"
>
<GripVertical className="h-5 w-5" />
</div>
<p
className={`flex-1 truncate text-sm ${isDelete ? "line-through text-muted-foreground" : ""}`}
>
{proxy.name}
</p>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onDelete}
>
{isDelete ? (
<Undo2 className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
);
};
export const ProxiesEditorViewer = (props: Props) => { export const ProxiesEditorViewer = (props: Props) => {
const { profileUid, property, open, onClose, onSave } = props; const { profileUid, property, open, onClose, onSave } = props;
const { t } = useTranslation(); const { t } = useTranslation();
@@ -83,6 +154,7 @@ export const ProxiesEditorViewer = (props: Props) => {
coordinateGetter: sortableKeyboardCoordinates, coordinateGetter: sortableKeyboardCoordinates,
}), }),
); );
const reorder = ( const reorder = (
list: IProxyConfig[], list: IProxyConfig[],
startIndex: number, startIndex: number,
@@ -93,44 +165,33 @@ export const ProxiesEditorViewer = (props: Props) => {
result.splice(endIndex, 0, removed); result.splice(endIndex, 0, removed);
return result; return result;
}; };
const onPrependDragEnd = async (event: DragEndEvent) => { const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (over) { if (over && active.id !== over.id) {
if (active.id !== over.id) { let activeIndex = 0;
let activeIndex = 0; let overIndex = 0;
let overIndex = 0; prependSeq.forEach((item, index) => {
prependSeq.forEach((item, index) => { if (item.name === active.id) activeIndex = index;
if (item.name === active.id) { if (item.name === over.id) overIndex = index;
activeIndex = index; });
} setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
if (item.name === over.id) {
overIndex = index;
}
});
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
} }
}; };
const onAppendDragEnd = async (event: DragEndEvent) => { const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (over) { if (over && active.id !== over.id) {
if (active.id !== over.id) { let activeIndex = 0;
let activeIndex = 0; let overIndex = 0;
let overIndex = 0; appendSeq.forEach((item, index) => {
appendSeq.forEach((item, index) => { if (item.name === active.id) activeIndex = index;
if (item.name === active.id) { if (item.name === over.id) overIndex = index;
activeIndex = index; });
} setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
if (item.name === over.id) {
overIndex = index;
}
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
} }
}; };
// 优化异步分片解析避免主线程阻塞解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => { const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
let proxies: IProxyConfig[] = []; let proxies: IProxyConfig[] = [];
let names: string[] = []; let names: string[] = [];
@@ -154,7 +215,7 @@ export const ProxiesEditorViewer = (props: Props) => {
names.push(proxy.name); names.push(proxy.name);
} }
} catch (err: any) { } catch (err: any) {
// 不阻塞主流程 // Ignore parse errors
} }
} }
if (idx < lines.length) { if (idx < lines.length) {
@@ -165,40 +226,39 @@ export const ProxiesEditorViewer = (props: Props) => {
} }
parseBatch(); parseBatch();
}; };
const fetchProfile = async () => { const fetchProfile = async () => {
let data = await readProfileFile(profileUid); let data = await readProfileFile(profileUid);
let originProxiesObj = yaml.load(data) as { let originProxiesObj = yaml.load(data) as {
proxies: IProxyConfig[]; proxies: IProxyConfig[];
} | null; } | null;
setProxyList(originProxiesObj?.proxies || []); setProxyList(originProxiesObj?.proxies || []);
}; };
const fetchContent = async () => { const fetchContent = async () => {
let data = await readProfileFile(property); let data = await readProfileFile(property);
let obj = yaml.load(data) as ISeqProfileConfig | null; let obj = yaml.load(data) as ISeqProfileConfig | null;
setPrependSeq(obj?.prepend || []); setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []); setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []); setDeleteSeq(obj?.delete || []);
setPrevData(data); setPrevData(data);
setCurrData(data); setCurrData(data);
}; };
useEffect(() => { useEffect(() => {
if (currData === "") return; if (currData === "" || visualization !== true) return;
if (visualization !== true) return; try {
let obj = yaml.load(currData) as {
let obj = yaml.load(currData) as { prepend: [];
prepend: []; append: [];
append: []; delete: [];
delete: []; } | null;
} | null; setPrependSeq(obj?.prepend || []);
setPrependSeq(obj?.prepend || []); setAppendSeq(obj?.append || []);
setAppendSeq(obj?.append || []); setDeleteSeq(obj?.delete || []);
setDeleteSeq(obj?.delete || []); } catch (e) {
console.error("Error parsing YAML in visualization mode:", e);
}
}, [visualization]); }, [visualization]);
useEffect(() => { useEffect(() => {
@@ -212,7 +272,7 @@ export const ProxiesEditorViewer = (props: Props) => {
), ),
); );
} catch (e) { } catch (e) {
// 防止异常导致UI卡死 console.error("Error dumping YAML:", e);
} }
}; };
if (window.requestIdleCallback) { if (window.requestIdleCallback) {
@@ -241,242 +301,202 @@ export const ProxiesEditorViewer = (props: Props) => {
}); });
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth> <Dialog open={open} onOpenChange={onClose}>
<DialogTitle> <DialogContent className="max-w-4xl h-[80vh] flex flex-col">
{ <DialogHeader>
<Box display="flex" justifyContent="space-between"> <div className="flex justify-between items-center">
{t("Edit Proxies")} <DialogTitle>{t("Edit Proxies")}</DialogTitle>
<Box> <Button
<Button variant="outline"
variant="contained" size="sm"
size="small" onClick={() => setVisualization((prev) => !prev)}
onClick={() => {
setVisualization((prev) => !prev);
}}
>
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</Box>
</Box>
}
</DialogTitle>
<DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
>
{visualization ? (
<>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
> >
<Box {visualization ? t("Advanced") : t("Visualization")}
sx={{ </Button>
height: "calc(100% - 80px)", </div>
overflowY: "auto", </DialogHeader>
}}
> <div className="flex-1 min-h-0">
<Item> {visualization ? (
<TextField <div className="h-full flex gap-4">
autoComplete="new-password" <div className="w-1/3 flex flex-col gap-4">
placeholder={t("Use newlines for multiple uri")} <Textarea
fullWidth placeholder={t("Use newlines for multiple uri")}
rows={9} className="flex-1"
multiline value={proxyUri}
size="small" onChange={(e) => setProxyUri(e.target.value)}
onChange={(e) => setProxyUri(e.target.value)} />
<div className="flex flex-col gap-2">
<Button
onClick={() =>
handleParseAsync((proxies) =>
setPrependSeq((prev) => [...proxies, ...prev]),
)
}
>
<ArrowUpToLine className="mr-2 h-4 w-4" />
{t("Prepend Proxy")}
</Button>
<Button
onClick={() =>
handleParseAsync((proxies) =>
setAppendSeq((prev) => [...prev, ...proxies]),
)
}
>
<ArrowDownToLine className="mr-2 h-4 w-4" />
{t("Append Proxy")}
</Button>
</div>
</div>
<Separator orientation="vertical" />
<div className="w-2/3 flex flex-col">
<BaseSearchBox
onSearch={(matcher) => setMatch(() => matcher)}
/>
<div className="flex-1 min-h-0 mt-2 rounded-md border">
<Virtuoso
className="h-full"
totalCount={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
itemContent={(index) => {
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => x.name)}
>
{filteredPrependSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="prepend"
proxy={item}
onDelete={() =>
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}
/>
))}
</SortableContext>
</DndContext>
);
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift;
const currentProxy = filteredProxyList[newIndex];
return (
<EditorProxyItem
key={currentProxy.name}
id={currentProxy.name}
p_type={
deleteSeq.includes(currentProxy.name)
? "delete"
: "original"
}
proxy={currentProxy}
onDelete={() => {
if (deleteSeq.includes(currentProxy.name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== currentProxy.name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
currentProxy.name,
]);
}
}}
/>
);
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => x.name)}
>
{filteredAppendSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="append"
proxy={item}
onDelete={() =>
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}
/>
))}
</SortableContext>
</DndContext>
);
}
}}
/> />
</Item> </div>
</Box> </div>
<Item> </div>
<Button ) : (
fullWidth <div className="h-full rounded-md border">
variant="contained" <MonacoEditor
startIcon={<VerticalAlignTopRounded />} height="100%"
onClick={() => { language="yaml"
handleParseAsync((proxies) => { value={currData}
setPrependSeq((prev) => [...proxies, ...prev]); theme={themeMode === "light" ? "vs" : "vs-dark"}
}); options={{
}} tabSize: 2,
> minimap: {
{t("Prepend Proxy")} enabled: document.documentElement.clientWidth >= 1500,
</Button> },
</Item> mouseWheelZoom: true,
<Item> quickSuggestions: {
<Button strings: true,
fullWidth comments: true,
variant="contained" other: true,
startIcon={<VerticalAlignBottomRounded />} },
onClick={() => { padding: { top: 16 },
handleParseAsync((proxies) => { fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${getSystem() === "windows" ? ", twemoji mozilla" : ""}`,
setAppendSeq((prev) => [...prev, ...proxies]); fontLigatures: false,
}); smoothScrolling: true,
}}
>
{t("Append Proxy")}
</Button>
</Item>
</List>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name;
})}
>
{filteredPrependSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
} else if (index < filteredProxyList.length + shift) {
let newIndex = index - shift;
return (
<ProxyItem
key={`${filteredProxyList[newIndex].name}-${index}`}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? "delete"
: "original"
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
]);
}
}}
/>
);
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name;
})}
>
{filteredAppendSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
}
}} }}
onChange={(value) => setCurrData(value)}
/> />
</List> </div>
</> )}
) : ( </div>
<MonacoEditor
height="100%" <DialogFooter className="mt-4">
language="yaml" <DialogClose asChild>
value={currData} <Button type="button" variant="outline">
theme={themeMode === "light" ? "vs" : "vs-dark"} {t("Cancel")}
options={{ </Button>
tabSize: 2, // 根据语言类型设置缩进大小 </DialogClose>
minimap: { <Button type="button" onClick={handleSave}>
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条 {t("Save")}
}, </Button>
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例 </DialogFooter>
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
},
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value)}
/>
)}
</DialogContent> </DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={handleSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog> </Dialog>
); );
}; };
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

@@ -1,14 +1,11 @@
import {
Box,
IconButton,
ListItem,
ListItemText,
alpha,
styled,
} from "@mui/material";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@root/lib/utils";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { GripVertical, Trash2, Undo2 } from "lucide-react";
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: "prepend" | "original" | "delete" | "append";
@@ -16,9 +13,19 @@ interface Props {
onDelete: () => void; onDelete: () => void;
} }
// Определяем стили для каждого типа элемента
const typeStyles = {
original: "bg-secondary/50",
delete: "bg-destructive/20 text-muted-foreground line-through",
prepend: "bg-green-500/20",
append: "bg-green-500/20",
};
export const ProxyItem = (props: Props) => { export const ProxyItem = (props: Props) => {
let { type, proxy, onDelete } = props; const { type, proxy, onDelete } = props;
const sortable = type === "prepend" || type === "append";
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
const { const {
attributes, attributes,
@@ -27,101 +34,50 @@ export const ProxyItem = (props: Props) => {
transform, transform,
transition, transition,
isDragging, isDragging,
} = sortable } = useSortable({ id: proxy.name, disabled: !isSortable });
? useSortable({ id: proxy.name })
: { const style = {
attributes: {}, transform: CSS.Transform.toString(transform),
listeners: {}, transition,
setNodeRef: null, zIndex: isDragging ? 100 : undefined,
transform: null, };
transition: null,
isDragging: false,
};
return ( return (
<ListItem <div
dense ref={setNodeRef}
sx={({ palette }) => ({ style={style}
position: "relative", // Применяем условные стили
background: className={cn(
type === "original" "flex items-center p-2 mb-1 rounded-lg transition-shadow",
? palette.mode === "dark" typeStyles[type],
? alpha(palette.background.paper, 0.3) isDragging && "shadow-lg"
: alpha(palette.grey[400], 0.3) )}
: type === "delete"
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
> >
<ListItemText {/* Ручка для перетаскивания */}
<div
{...attributes} {...attributes}
{...listeners} {...listeners}
ref={setNodeRef} className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
sx={{ cursor: sortable ? "move" : "" }} >
primary={ <GripVertical className="h-5 w-5" />
<StyledPrimary </div>
title={proxy.name}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }} {/* Название и тип прокси */}
> <div className="flex-1 min-w-0 ml-2">
{proxy.name} <p className="text-sm font-semibold truncate" title={proxy.name}>{proxy.name}</p>
</StyledPrimary> <div className="flex items-center text-xs text-muted-foreground mt-1">
} <Badge variant="outline">{proxy.type}</Badge>
secondary={ </div>
<ListItemTextChild </div>
sx={{
overflow: "hidden", {/* Кнопка действия */}
display: "flex", <Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
alignItems: "center", {type === "delete" ? (
pt: "2px", <Undo2 className="h-4 w-4" />
}} ) : (
> <Trash2 className="h-4 w-4 text-destructive" />
<Box sx={{ marginTop: "2px" }}> )}
<StyledTypeBox>{proxy.type}</StyledTypeBox> </Button>
</Box> </div>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
); );
}; };
const StyledPrimary = styled("div")`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ListItemTextChild = styled("span")`
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
lineHeight: 1.5,
marginRight: "8px",
}));

View File

@@ -1,25 +1,44 @@
import {
Box,
IconButton,
ListItem,
ListItemText,
alpha,
styled,
} from "@mui/material";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { cn } from "@root/lib/utils";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { GripVertical, Trash2, Undo2 } from "lucide-react";
interface Props { interface Props {
type: "prepend" | "original" | "delete" | "append"; type: "prepend" | "original" | "delete" | "append";
ruleRaw: string; ruleRaw: string;
onDelete: () => void; onDelete: () => void;
} }
export const RuleItem = (props: Props) => { // Определяем стили для каждого типа элемента
let { type, ruleRaw, onDelete } = props; const typeStyles = {
const sortable = type === "prepend" || type === "append"; original: "bg-secondary/50",
const rule = ruleRaw.replace(",no-resolve", ""); delete: "bg-destructive/20 text-muted-foreground line-through",
prepend: "bg-green-500/20",
append: "bg-green-500/20",
};
// Вспомогательная функция для цвета политики прокси
const PROXY_COLOR_CLASSES = ["text-sky-500", "text-violet-500", "text-amber-500", "text-lime-500", "text-emerald-500"];
const getProxyColorClass = (proxyName: string): string => {
if (proxyName === "REJECT" || proxyName === "REJECT-DROP") return "text-destructive";
if (proxyName === "DIRECT") return "text-primary";
let sum = 0;
for (let i = 0; i < proxyName.length; i++) sum += proxyName.charCodeAt(i);
return PROXY_COLOR_CLASSES[sum % PROXY_COLOR_CLASSES.length];
};
export const RuleItem = (props: Props) => {
const { type, ruleRaw, onDelete } = props;
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
// Логика парсинга строки правила остается без изменений
const rule = ruleRaw.replace(",no-resolve", "");
const ruleType = rule.match(/^[^,]+/)?.[0] ?? ""; const ruleType = rule.match(/^[^,]+/)?.[0] ?? "";
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? ""; const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1); const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
@@ -31,112 +50,55 @@ export const RuleItem = (props: Props) => {
transform, transform,
transition, transition,
isDragging, isDragging,
} = sortable } = useSortable({ id: ruleRaw, disabled: !isSortable });
? useSortable({ id: ruleRaw })
: { const style = {
attributes: {}, transform: CSS.Transform.toString(transform),
listeners: {}, transition,
setNodeRef: null, zIndex: isDragging ? 100 : undefined,
transform: null, };
transition: null,
isDragging: false,
};
return ( return (
<ListItem <div
dense ref={setNodeRef}
sx={({ palette }) => ({ style={style}
position: "relative", // Применяем условные стили
background: className={cn(
type === "original" "flex items-center p-2 mb-1 rounded-lg transition-shadow",
? palette.mode === "dark" typeStyles[type],
? alpha(palette.background.paper, 0.3) isDragging && "shadow-lg"
: alpha(palette.grey[400], 0.3) )}
: type === "delete"
? alpha(palette.error.main, 0.3)
: alpha(palette.success.main, 0.3),
height: "100%",
margin: "8px 0",
borderRadius: "8px",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
> >
<ListItemText {/* Ручка для перетаскивания */}
<div
{...attributes} {...attributes}
{...listeners} {...listeners}
ref={setNodeRef} className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
sx={{ cursor: sortable ? "move" : "" }} >
primary={ <GripVertical className="h-5 w-5" />
<StyledPrimary </div>
title={ruleContent || "-"}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }} {/* Основной контент */}
> <div className="flex-1 min-w-0 ml-2">
{ruleContent || "-"} <p className="text-sm font-semibold truncate" title={ruleContent || "-"}>
</StyledPrimary> {ruleContent || "-"}
} </p>
secondary={ <div className="flex items-center justify-between text-xs mt-1">
<ListItemTextChild <Badge variant="outline">{ruleType}</Badge>
sx={{ <p className={cn("font-medium", getProxyColorClass(proxyPolicy))}>
width: "62%", {proxyPolicy}
overflow: "hidden", </p>
display: "flex", </div>
justifyContent: "space-between", </div>
pt: "2px",
}} {/* Кнопка действия */}
> <Button variant="ghost" size="icon" className="h-8 w-8 ml-2" onClick={onDelete}>
<Box sx={{ marginTop: "2px" }}> {type === "delete" ? (
<StyledTypeBox>{ruleType}</StyledTypeBox> <Undo2 className="h-4 w-4" />
</Box> ) : (
<StyledSubtitle sx={{ color: "text.secondary" }}> <Trash2 className="h-4 w-4 text-destructive" />
{proxyPolicy} )}
</StyledSubtitle> </Button>
</ListItemTextChild> </div>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
); );
}; };
const StyledPrimary = styled("div")`
font-size: 15px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledSubtitle = styled("span")`
font-size: 13px;
overflow: hidden;
color: text.secondary;
text-overflow: ellipsis;
white-space: nowrap;
`;
const ListItemTextChild = styled("span")`
display: block;
`;
const StyledTypeBox = styled(ListItemTextChild)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
lineHeight: 1.5,
marginRight: "8px",
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,24 @@
import { useState } from "react"; import { useState } from "react";
import {
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
List,
ListItem,
ListItemText,
Typography,
Divider,
LinearProgress,
alpha,
styled,
useTheme,
} from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { proxyProviderUpdate } from "@/services/api"; import { proxyProviderUpdate } from "@/services/api";
import { useAppData } from "@/providers/app-data-provider"; import { useAppData } from "@/providers/app-data-provider";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material"; import { Database, RefreshCw } from "lucide-react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import parseTraffic from "@/utils/parse-traffic"; import parseTraffic from "@/utils/parse-traffic";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
// 定义代理提供者类型 // 定义代理提供者类型
interface ProxyProviderItem { interface ProxyProviderItem {
name?: string; name?: string;
@@ -40,19 +33,6 @@ interface ProxyProviderItem {
}; };
} }
// 样式化组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5),
color: alpha(theme.palette.secondary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
// 解析过期时间 // 解析过期时间
const parseExpire = (expire?: number) => { const parseExpire = (expire?: number) => {
if (!expire) return "-"; if (!expire) return "-";
@@ -61,7 +41,6 @@ const parseExpire = (expire?: number) => {
export const ProviderButton = () => { export const ProviderButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData(); const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({}); const [updating, setUpdating] = useState<Record<string, boolean>>({});
@@ -138,52 +117,40 @@ export const ProviderButton = () => {
} }
}); });
const handleClose = () => {
setOpen(false);
};
if (!hasProviders) return null; if (!hasProviders) return null;
return ( return (
<> <Dialog open={open} onOpenChange={setOpen}>
<Button <DialogTrigger asChild>
variant="outlined" <Button variant="outline" size="sm" className="mr-1">
size="small" <Database className="mr-2 h-4 w-4" />
startIcon={<StorageOutlined />} {t("Proxy Provider")}
onClick={() => setOpen(true)} </Button>
sx={{ mr: 1 }} </DialogTrigger>
> <DialogContent className="max-w-md sm:max-w-lg md:max-w-xl lg:max-w-2xl max-h-[80vh] flex flex-col">
{t("Proxy Provider")} <DialogHeader>
</Button> <DialogTitle>
<div className="flex justify-between items-center">
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> <span>{t("Proxy Provider")}</span>
<DialogTitle>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Typography variant="h6">{t("Proxy Provider")}</Typography>
<Box>
<Button <Button
variant="contained" variant="default"
size="small" size="sm"
onClick={updateAllProviders} onClick={updateAllProviders}
disabled={Object.values(updating).some(Boolean)}
> >
{t("Update All")} {t("Update All")}
</Button> </Button>
</Box> </div>
</Box> </DialogTitle>
</DialogTitle> </DialogHeader>
<DialogContent> <div className="flex-grow overflow-y-auto py-0 px-1 my-2">
<List sx={{ py: 0, minHeight: 250 }}> <div className="space-y-2">
{Object.entries(proxyProviders || {}).map(([key, item]) => { {Object.entries(proxyProviders || {}).map(([key, item]) => {
const provider = item as ProxyProviderItem; const provider = item as ProxyProviderItem;
const time = dayjs(provider.updatedAt); const time = dayjs(provider.updatedAt);
const isUpdating = updating[key]; const isUpdating = updating[key];
// 订阅信息
const sub = provider.subscriptionInfo; const sub = provider.subscriptionInfo;
const hasSubInfo = !!sub; const hasSubInfo = !!sub;
const upload = sub?.Upload || 0; const upload = sub?.Upload || 0;
@@ -191,7 +158,6 @@ export const ProviderButton = () => {
const total = sub?.Total || 0; const total = sub?.Total || 0;
const expire = sub?.Expire || 0; const expire = sub?.Expire || 0;
// 流量使用进度
const progress = const progress =
total > 0 total > 0
? Math.min( ? Math.min(
@@ -200,148 +166,78 @@ export const ProviderButton = () => {
) )
: 0; : 0;
const TypeBoxDisplay = ({
children,
}: {
children: React.ReactNode;
}) => (
<span className="inline-block border border-border text-xs text-muted-foreground rounded px-1 py-0.5 mr-1">
{children}
</span>
);
return ( return (
<ListItem <div
key={key} key={key}
sx={[ className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm flex items-center"
{
p: 0,
mb: "8px",
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s",
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const hoverColor =
mode === "light"
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return {
backgroundColor: bgcolor,
"&:hover": {
backgroundColor: hoverColor,
},
};
},
]}
> >
<ListItemText <div className="flex-grow space-y-1">
sx={{ px: 2, py: 1 }} <div className="flex justify-between items-center">
primary={ <div className="flex items-center font-semibold truncate">
<Box <span className="mr-2 truncate" title={key}>
sx={{ {key}
display: "flex", </span>
justifyContent: "space-between", <TypeBoxDisplay>
alignItems: "center", {provider.proxies.length}
}} </TypeBoxDisplay>
> <TypeBoxDisplay>{provider.vehicleType}</TypeBoxDisplay>
<Typography </div>
variant="subtitle1" <div className="text-xs text-muted-foreground whitespace-nowrap">
component="div" <small>{t("Update At")}: </small>
noWrap {time.fromNow()}
title={key} </div>
sx={{ display: "flex", alignItems: "center" }} </div>
> {hasSubInfo && (
<span style={{ marginRight: "8px" }}>{key}</span> <div className="text-xs">
<TypeBox component="span"> <div className="flex items-center justify-between mb-1">
{provider.proxies.length} <span title={t("Used / Total") as string}>
</TypeBox> {parseTraffic(upload + download)} /{" "}
<TypeBox component="span"> {parseTraffic(total)}
{provider.vehicleType} </span>
</TypeBox> <span title={t("Expire Time") as string}>
</Typography> {parseExpire(expire)}
</span>
<Typography </div>
variant="body2" {total > 0 && (
color="text.secondary" <Progress value={progress} className="h-1.5" />
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span title={t("Used / Total") as string}>
{parseTraffic(upload + download)} /{" "}
{parseTraffic(total)}
</span>
<span title={t("Expire Time") as string}>
{parseExpire(expire)}
</span>
</Box>
{/* 进度条 */}
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 3,
opacity: total > 0 ? 1 : 0,
}}
/>
</>
)} )}
</> </div>
} )}
/> </div>
<Divider orientation="vertical" flexItem /> <div className="pl-3 ml-3 border-l border-border flex-shrink-0">
<Box <Button
sx={{ variant="ghost"
width: 40, size="icon"
display: "flex", onClick={() => updateProvider(key)}
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
size="small"
color="primary"
onClick={(e) => {
updateProvider(key);
}}
disabled={isUpdating} disabled={isUpdating}
sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
title={t("Update Provider") as string} title={t("Update Provider") as string}
className={isUpdating ? "animate-spin" : ""}
> >
<RefreshRounded /> <RefreshCw className="h-4 w-4" />
</IconButton> </Button>
</Box> </div>
</ListItem> </div>
); );
})} })}
</List> </div>
</DialogContent> </div>
<DialogActions> <DialogFooter>
<Button onClick={handleClose} variant="outlined"> <Button variant="outline" onClick={() => setOpen(false)}>
{t("Close")} {t("Close")}
</Button> </Button>
</DialogActions> </DialogFooter>
</Dialog> </DialogContent>
</> </Dialog>
); );
}; };

View File

@@ -1,4 +1,11 @@
import { useRef, useState, useEffect, useCallback, useMemo } from "react"; import React, {
useRef,
useState,
useEffect,
useCallback,
useMemo,
memo,
} from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Virtuoso, type VirtuosoHandle } from "react-virtuoso"; import { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import { import {
@@ -16,550 +23,14 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button"; import { ScrollTopButton } from "../layout/scroll-top-button";
import { Box, styled } from "@mui/material"; import {
import { memo } from "react"; Tooltip,
import { createPortal } from "react-dom"; TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// 将选择器组件抽离出来,避免主组件重渲染时重复创建样式 // Вспомогательная функция для плавного скролла (взята из вашего оригинального файла)
const AlphabetSelector = styled(Box)(({ theme }) => ({
position: "fixed",
right: 4,
top: "50%",
transform: "translateY(-50%)",
display: "flex",
flexDirection: "column",
background: "transparent",
zIndex: 1000,
gap: "2px",
// padding: "4px 2px",
willChange: "transform",
"&:hover": {
background: theme.palette.background.paper,
boxShadow: theme.shadows[2],
borderRadius: "8px",
},
"& .scroll-container": {
overflow: "hidden",
maxHeight: "inherit",
willChange: "transform",
},
"& .letter-container": {
display: "flex",
flexDirection: "column",
gap: "2px",
transition: "transform 0.2s ease",
willChange: "transform",
},
"& .letter": {
padding: "1px 4px",
fontSize: "12px",
cursor: "pointer",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
color: theme.palette.text.secondary,
position: "relative",
width: "1.5em",
height: "1.5em",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1)",
transform: "scale(1) translateZ(0)",
backfaceVisibility: "hidden",
borderRadius: "6px",
"&:hover": {
color: theme.palette.primary.main,
transform: "scale(1.4) translateZ(0)",
backgroundColor: theme.palette.action.hover,
},
},
}));
// 创建一个单独的 Tooltip 组件
const Tooltip = styled("div")(({ theme }) => ({
position: "fixed",
background: theme.palette.background.paper,
padding: "4px 8px",
borderRadius: "6px",
boxShadow: theme.shadows[3],
whiteSpace: "nowrap",
fontSize: "16px",
color: theme.palette.text.primary,
pointerEvents: "none",
"&::after": {
content: '""',
position: "absolute",
right: "-4px",
top: "50%",
transform: "translateY(-50%)",
width: 0,
height: 0,
borderTop: "4px solid transparent",
borderBottom: "4px solid transparent",
borderLeft: `4px solid ${theme.palette.background.paper}`,
},
}));
// 抽离字母选择器子组件
const LetterItem = memo(
({
name,
onClick,
getFirstChar,
enableAutoScroll = true,
}: {
name: string;
onClick: (name: string) => void;
getFirstChar: (str: string) => string;
enableAutoScroll?: boolean;
}) => {
const [showTooltip, setShowTooltip] = useState(false);
const letterRef = useRef<HTMLDivElement>(null);
const [tooltipPosition, setTooltipPosition] = useState({
top: 0,
right: 0,
});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const updateTooltipPosition = useCallback(() => {
if (!letterRef.current) return;
const rect = letterRef.current.getBoundingClientRect();
setTooltipPosition({
top: rect.top + rect.height / 2,
right: window.innerWidth - rect.left + 8,
});
}, []);
useEffect(() => {
if (showTooltip) {
updateTooltipPosition();
}
}, [showTooltip, updateTooltipPosition]);
const handleMouseEnter = useCallback(() => {
setShowTooltip(true);
// 只有在启用自动滚动时才触发滚动
if (enableAutoScroll) {
// 添加 100ms 的延迟,避免鼠标快速划过时触发滚动
hoverTimeoutRef.current = setTimeout(() => {
onClick(name);
}, 100);
}
}, [name, onClick, enableAutoScroll]);
const handleMouseLeave = useCallback(() => {
setShowTooltip(false);
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
}, []);
useEffect(() => {
return () => {
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
}
};
}, []);
return (
<>
<div
ref={letterRef}
className="letter"
onClick={() => onClick(name)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<span>{getFirstChar(name)}</span>
</div>
{showTooltip &&
createPortal(
<Tooltip
style={{
top: tooltipPosition.top,
right: tooltipPosition.right,
transform: "translateY(-50%)",
}}
>
{name}
</Tooltip>,
document.body,
)}
</>
);
},
);
interface Props {
mode: string;
}
export const ProxyGroups = (props: Props) => {
const { t } = useTranslation();
const { mode } = props;
const { renderList, onProxies, onHeadState } = useRenderList(mode);
const { verge } = useVerge();
const { current, patchCurrent } = useProfiles();
// 获取自动滚动开关状态,默认为 true
const enableAutoScroll = verge?.enable_hover_jump_navigator ?? true;
const timeout = verge?.default_latency_timeout || 10000;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollPositionRef = useRef<Record<string, number>>({});
const [showScrollTop, setShowScrollTop] = useState(false);
const scrollerRef = useRef<Element | null>(null);
const letterContainerRef = useRef<HTMLDivElement>(null);
const alphabetSelectorRef = useRef<HTMLDivElement>(null);
const [maxHeight, setMaxHeight] = useState("auto");
// 使用useMemo缓存字母索引数据
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const letters = new Set<string>();
const indexMap: Record<string, number> = {};
renderList.forEach((item, index) => {
if (item.type === 0) {
const fullName = item.group.name;
letters.add(fullName);
if (!(fullName in indexMap)) {
indexMap[fullName] = index;
}
}
});
return {
groupFirstLetters: Array.from(letters),
letterIndexMap: indexMap,
};
}, [renderList]);
// 缓存getFirstChar函数
const getFirstChar = useCallback((str: string) => {
const regex =
/\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u;
const match = str.match(regex);
return match ? match[0] : str.charAt(0);
}, []);
// 从 localStorage 恢复滚动位置
useEffect(() => {
if (renderList.length === 0) return;
try {
const savedPositions = localStorage.getItem("proxy-scroll-positions");
if (savedPositions) {
const positions = JSON.parse(savedPositions);
scrollPositionRef.current = positions;
const savedPosition = positions[mode];
if (savedPosition !== undefined) {
setTimeout(() => {
virtuosoRef.current?.scrollTo({
top: savedPosition,
behavior: "auto",
});
}, 100);
}
}
} catch (e) {
console.error("Error restoring scroll position:", e);
}
}, [mode, renderList]);
// 改为使用节流函数保存滚动位置
const saveScrollPosition = useCallback(
(scrollTop: number) => {
try {
scrollPositionRef.current[mode] = scrollTop;
localStorage.setItem(
"proxy-scroll-positions",
JSON.stringify(scrollPositionRef.current),
);
} catch (e) {
console.error("Error saving scroll position:", e);
}
},
[mode],
);
// 使用改进的滚动处理
const handleScroll = useCallback(
throttle((e: any) => {
const scrollTop = e.target.scrollTop;
setShowScrollTop(scrollTop > 100);
// 使用稳定的节流来保存位置而不是setTimeout
saveScrollPosition(scrollTop);
}, 500), // 增加到500ms以确保平滑滚动
[saveScrollPosition],
);
// 添加和清理滚动事件监听器
useEffect(() => {
const currentScroller = scrollerRef.current;
if (currentScroller) {
currentScroller.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => {
currentScroller.removeEventListener("scroll", handleScroll);
};
}
}, [handleScroll]);
// 滚动到顶部
const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo?.({
top: 0,
behavior: "smooth",
});
saveScrollPosition(0);
}, [saveScrollPosition]);
// 处理字母点击使用useCallback
const handleLetterClick = useCallback(
(name: string) => {
const index = letterIndexMap[name];
if (index !== undefined) {
virtuosoRef.current?.scrollToIndex({
index,
align: "start",
behavior: "smooth",
});
}
},
[letterIndexMap],
);
// 切换分组的节点代理
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return;
const { name, now } = group;
await updateProxy(name, proxy.name);
onProxies();
// 断开连接
if (verge?.auto_close_connection) {
getConnections().then(({ connections }) => {
connections.forEach((conn) => {
if (conn.chains.includes(now!)) {
deleteConnection(conn.id);
}
});
});
}
// 保存到selected中
if (!current) return;
if (!current.selected) current.selected = [];
const index = current.selected.findIndex(
(item) => item.name === group.name,
);
if (index < 0) {
current.selected.push({ name, now: proxy.name });
} else {
current.selected[index] = { name, now: proxy.name };
}
await patchCurrent({ selected: current.selected });
},
);
// 测全部延迟
const handleCheckAll = useLockFn(async (groupName: string) => {
console.log(`[ProxyGroups] 开始测试所有延迟,组: ${groupName}`);
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
)
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean);
console.log(`[ProxyGroups] 找到代理数量: ${proxies.length}`);
const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean));
if (providers.size) {
console.log(`[ProxyGroups] 发现提供者,数量: ${providers.size}`);
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)),
).then(() => {
console.log(`[ProxyGroups] 提供者健康检查完成`);
onProxies();
});
}
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
console.log(`[ProxyGroups] 过滤后需要测试的代理数量: ${names.length}`);
const url = delayManager.getUrl(groupName);
console.log(`[ProxyGroups] 测试URL: ${url}, 超时: ${timeout}ms`);
try {
await Promise.race([
delayManager.checkListDelay(names, groupName, timeout),
getGroupProxyDelays(groupName, url, timeout).then((result) => {
console.log(
`[ProxyGroups] getGroupProxyDelays返回结果数量:`,
Object.keys(result || {}).length,
);
}), // 查询group delays 将清除fixed(不关注调用结果)
]);
console.log(`[ProxyGroups] 延迟测试完成,组: ${groupName}`);
} catch (error) {
console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error);
}
onProxies();
});
// 滚到对应的节点
const handleLocation = (group: IProxyGroupItem) => {
if (!group) return;
const { name, now } = group;
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
);
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: "center",
behavior: "smooth",
});
}
};
// 添加滚轮事件处理函数 - 改进为只在悬停时触发
const handleWheel = useCallback((e: WheelEvent) => {
// 只有当鼠标在字母选择器上时才处理滚轮事件
if (!alphabetSelectorRef.current?.contains(e.target as Node)) return;
e.preventDefault();
if (!letterContainerRef.current) return;
const container = letterContainerRef.current;
const scrollAmount = e.deltaY;
const currentTransform = new WebKitCSSMatrix(container.style.transform);
const currentY = currentTransform.m42 || 0;
const containerHeight = container.getBoundingClientRect().height;
const parentHeight =
container.parentElement?.getBoundingClientRect().height || 0;
const maxScroll = Math.max(0, containerHeight - parentHeight);
let newY = currentY - scrollAmount;
newY = Math.min(0, Math.max(-maxScroll, newY));
container.style.transform = `translateY(${newY}px)`;
}, []);
// 添加和移除滚轮事件监听
useEffect(() => {
const container = letterContainerRef.current?.parentElement;
if (container) {
container.addEventListener("wheel", handleWheel, { passive: false });
return () => {
container.removeEventListener("wheel", handleWheel);
};
}
}, [handleWheel]);
// 添加窗口大小变化监听和最大高度计算
const updateMaxHeight = useCallback(() => {
if (!alphabetSelectorRef.current) return;
const windowHeight = window.innerHeight;
const bottomMargin = 60; // 底部边距
const topMargin = bottomMargin * 2; // 顶部边距是底部的2倍
const availableHeight = windowHeight - (topMargin + bottomMargin);
// 调整选择器的位置,使其偏下
const offsetPercentage =
(((topMargin - bottomMargin) / windowHeight) * 100) / 2;
alphabetSelectorRef.current.style.top = `calc(48% + ${offsetPercentage}vh)`;
setMaxHeight(`${availableHeight}px`);
}, []);
// 监听窗口大小变化
useEffect(() => {
updateMaxHeight();
window.addEventListener("resize", updateMaxHeight);
return () => {
window.removeEventListener("resize", updateMaxHeight);
};
}, [updateMaxHeight]);
if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />;
}
return (
<div
style={{ position: "relative", height: "100%", willChange: "transform" }}
>
<Virtuoso
ref={virtuosoRef}
style={{ height: "calc(100% - 14px)" }}
totalCount={renderList.length}
increaseViewportBy={{ top: 200, bottom: 200 }}
overscan={150}
defaultItemHeight={56}
scrollerRef={(ref) => {
scrollerRef.current = ref as Element;
}}
components={{
Footer: () => <div style={{ height: "8px" }} />,
}}
// 添加平滑滚动设置
initialScrollTop={scrollPositionRef.current[mode]}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
key={renderList[index].key}
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
<AlphabetSelector ref={alphabetSelectorRef} style={{ maxHeight }}>
<div className="scroll-container">
<div ref={letterContainerRef} className="letter-container">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
enableAutoScroll={enableAutoScroll}
/>
))}
</div>
</div>
</AlphabetSelector>
</div>
);
};
// 替换简单防抖函数为更优的节流函数
function throttle<T extends (...args: any[]) => any>( function throttle<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number,
@@ -588,14 +59,248 @@ function throttle<T extends (...args: any[]) => any>(
}; };
} }
// 保留防抖函数以兼容其他地方可能的使用 // Компонент для одной буквы в навигаторе, переписанный на Tailwind и shadcn/ui
function debounce<T extends (...args: any[]) => any>( const LetterItem = memo(
func: T, ({
wait: number, name,
): (...args: Parameters<T>) => void { onClick,
let timeout: ReturnType<typeof setTimeout> | null = null; getFirstChar,
return (...args: Parameters<T>) => { }: {
if (timeout) clearTimeout(timeout); name: string;
timeout = setTimeout(() => func(...args), wait); onClick: (name: string) => void;
}; getFirstChar: (str: string) => string;
}) => {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex items-center justify-center w-6 h-6 text-xs rounded-md cursor-pointer text-muted-foreground transition-transform hover:bg-accent hover:text-accent-foreground hover:scale-125"
onClick={() => onClick(name)}
>
{getFirstChar(name)}
</div>
</TooltipTrigger>
<TooltipContent side="left">
<p>{name}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
},
);
interface Props {
mode: string;
} }
// Основной компонент, обернутый в memo для производительности
export const ProxyGroups = memo((props: Props) => {
const { t } = useTranslation();
const { mode } = props;
const { renderList, onProxies, onHeadState } = useRenderList(mode);
const { verge } = useVerge();
const { current, patchCurrent } = useProfiles();
const timeout = verge?.default_latency_timeout || 10000;
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<Element | null>(null);
const [showScrollTop, setShowScrollTop] = useState(false);
// Мемоизация вычисления букв и индексов для навигатора
const { groupFirstLetters, letterIndexMap } = useMemo(() => {
const letters = new Set<string>();
const indexMap: Record<string, number> = {};
renderList.forEach((item, index) => {
if (item.type === 0) {
// type 0 - это заголовок группы
const fullName = item.group.name;
letters.add(fullName);
if (!(fullName in indexMap)) {
indexMap[fullName] = index;
}
}
});
return {
groupFirstLetters: Array.from(letters),
letterIndexMap: indexMap,
};
}, [renderList]);
// Мемоизация функции для получения первой буквы (поддерживает эмодзи)
const getFirstChar = useCallback((str: string) => {
const match = str.match(
/\p{Regional_Indicator}{2}|\p{Extended_Pictographic}|\p{L}|\p{N}|./u,
);
return match ? match[0] : str.charAt(0);
}, []);
// Обработчик скролла для показа/скрытия кнопки "Наверх"
const handleScroll = useCallback(
throttle((e: any) => {
setShowScrollTop(e.target.scrollTop > 100);
}, 200),
[],
);
// Добавление и удаление слушателя скролла
useEffect(() => {
const currentScroller = scrollerRef.current;
if (currentScroller) {
currentScroller.addEventListener("scroll", handleScroll, {
passive: true,
});
return () => {
currentScroller.removeEventListener("scroll", handleScroll);
};
}
}, [handleScroll]);
const scrollToTop = useCallback(() => {
virtuosoRef.current?.scrollTo({ top: 0, behavior: "smooth" });
}, []);
const handleLetterClick = useCallback(
(name: string) => {
const index = letterIndexMap[name];
if (index !== undefined) {
virtuosoRef.current?.scrollToIndex({
index,
align: "start",
behavior: "smooth",
});
}
},
[letterIndexMap],
);
// Вся бизнес-логика из оригинального файла
const handleChangeProxy = useLockFn(
async (group: IProxyGroupItem, proxy: IProxyItem) => {
if (!["Selector", "URLTest", "Fallback"].includes(group.type)) return;
const { name, now } = group;
await updateProxy(name, proxy.name);
onProxies();
if (verge?.auto_close_connection) {
getConnections().then(({ connections }) => {
connections.forEach((conn) => {
if (conn.chains.includes(now!)) {
deleteConnection(conn.id);
}
});
});
}
if (!current || !current.selected) return;
const index = current.selected.findIndex(
(item) => item.name === group.name,
);
if (index < 0) {
current.selected.push({ name, now: proxy.name });
} else {
current.selected[index] = { name, now: proxy.name };
}
await patchCurrent({ selected: current.selected });
},
);
const handleCheckAll = useLockFn(async (groupName: string) => {
const proxies = renderList
.filter(
(e) => e.group?.name === groupName && (e.type === 2 || e.type === 4),
)
.flatMap((e) => e.proxyCol || e.proxy!)
.filter(Boolean);
const providers = new Set(proxies.map((p) => p!.provider!).filter(Boolean));
if (providers.size) {
Promise.allSettled(
[...providers].map((p) => providerHealthCheck(p)),
).then(() => {
onProxies();
});
}
const names = proxies.filter((p) => !p!.provider).map((p) => p!.name);
const url = delayManager.getUrl(groupName);
try {
await Promise.race([
delayManager.checkListDelay(names, groupName, timeout),
getGroupProxyDelays(groupName, url, timeout),
]);
} catch (error) {
console.error(
`[ProxyGroups] Latency test error, group: ${groupName}`,
error,
);
}
onProxies();
});
const handleLocation = (group: IProxyGroupItem) => {
if (!group) return;
const { name, now } = group;
const index = renderList.findIndex(
(e) =>
e.group?.name === name &&
((e.type === 2 && e.proxy?.name === now) ||
(e.type === 4 && e.proxyCol?.some((p) => p.name === now))),
);
if (index >= 0) {
virtuosoRef.current?.scrollToIndex?.({
index,
align: "center",
behavior: "smooth",
});
}
};
// Отображение заглушки для режима Direct
if (mode === "direct") {
return <BaseEmpty text={t("clash_mode_direct")} />;
}
return (
<div className="relative h-full">
<Virtuoso
ref={virtuosoRef}
style={{ height: "100%" }}
data={renderList}
scrollerRef={(ref) => (scrollerRef.current = ref as Element)}
components={{ Footer: () => <div style={{ height: "8px" }} /> }}
computeItemKey={(index) => renderList[index].key}
itemContent={(index) => (
<ProxyRender
item={renderList[index]}
indent={mode === "rule" || mode === "script"}
onLocation={handleLocation}
onCheckAll={handleCheckAll}
onHeadState={onHeadState}
onChangeProxy={handleChangeProxy}
/>
)}
/>
<ScrollTopButton show={showScrollTop} onClick={scrollToTop} />
{/* Алфавитный указатель */}
<div className="fixed top-1/2 right-4 z-50 flex -translate-y-1/2 flex-col gap-1 rounded-md bg-background/50 p-1 backdrop-blur-sm">
{groupFirstLetters.map((name) => (
<LetterItem
key={name}
name={name}
onClick={handleLetterClick}
getFirstChar={getFirstChar}
/>
))}
</div>
</div>
);
});

View File

@@ -1,26 +1,37 @@
// ProxyHead.tsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Box, IconButton, TextField, SxProps } from "@mui/material";
import {
AccessTimeRounded,
MyLocationRounded,
NetworkCheckRounded,
FilterAltRounded,
FilterAltOffRounded,
VisibilityRounded,
VisibilityOffRounded,
WifiTetheringRounded,
WifiTetheringOffRounded,
SortByAlphaRounded,
SortRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import type { HeadState } from "./use-head-state"; import type { HeadState } from "./use-head-state";
import type { ProxySortType } from "./use-filter-sort"; import type { ProxySortType } from "./use-filter-sort";
import delayManager from "@/services/delay"; import delayManager from "@/services/delay";
// Утилиты и компоненты shadcn/ui
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@root/lib/utils";
// Иконки
import {
LocateFixed,
Network,
ArrowUpDown,
Timer,
ArrowDownAZ,
Wifi,
Eye,
Filter,
} from "lucide-react";
interface Props { interface Props {
sx?: SxProps;
url?: string; url?: string;
groupName: string; groupName: string;
headState: HeadState; headState: HeadState;
@@ -30,140 +41,181 @@ interface Props {
} }
export const ProxyHead = (props: Props) => { export const ProxyHead = (props: Props) => {
const { sx = {}, url, groupName, headState, onHeadState } = props; const { url, groupName, headState, onHeadState } = props;
const { showType, sortType, filterText, textState, testUrl } = headState; const { showType, sortType, filterText, textState, testUrl } = headState;
const { t } = useTranslation(); const { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false); const { verge } = useVerge();
const [autoFocus, setAutoFocus] = useState(false);
useEffect(() => { useEffect(() => {
// fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100); const timer = setTimeout(() => setAutoFocus(true), 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
const { verge } = useVerge();
useEffect(() => { useEffect(() => {
delayManager.setUrl( delayManager.setUrl(
groupName, groupName,
testUrl || url || verge?.default_latency_test!, testUrl || url || verge?.default_latency_test!,
); );
}, [groupName, testUrl, verge?.default_latency_test]); }, [groupName, testUrl, url, verge?.default_latency_test]);
const getToggleVariant = (isActive: boolean) =>
isActive ? "secondary" : "ghost";
return ( return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, ...sx }}> <TooltipProvider delayDuration={100}>
<IconButton <div className="flex h-10 items-center justify-between px-2">
size="small" <div className="flex items-center gap-1">
color="inherit" <Tooltip>
title={t("locate")} <TooltipTrigger asChild>
onClick={props.onLocation} <Button
> variant="ghost"
<MyLocationRounded /> size="icon"
</IconButton> title={t("locate")}
onClick={props.onLocation}
className="h-8 w-8"
>
<LocateFixed className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Locate Current Proxy")}</p>
</TooltipContent>
</Tooltip>
<IconButton <Tooltip>
size="small" <TooltipTrigger asChild>
color="inherit" <Button
title={t("Delay check")} variant="ghost"
onClick={() => { size="icon"
console.log(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`); title={t("Delay check")}
// Remind the user that it is custom test url onClick={props.onCheckDelay}
if (testUrl?.trim() && textState !== "filter") { className="h-8 w-8"
console.log(`[ProxyHead] 使用自定义测试URL: ${testUrl}`); >
onHeadState({ textState: "url" }); <Network className="h-5 w-5" />
} </Button>
props.onCheckDelay(); </TooltipTrigger>
}} <TooltipContent>
> <p>{t("Check Group Latency")}</p>
<NetworkCheckRounded /> </TooltipContent>
</IconButton> </Tooltip>
<IconButton <Separator orientation="vertical" className="h-6 mx-1" />
size="small"
color="inherit"
title={
[t("Sort by default"), t("Sort by delay"), t("Sort by name")][
sortType
]
}
onClick={() =>
onHeadState({ sortType: ((sortType + 1) % 3) as ProxySortType })
}
>
{sortType !== 1 && sortType !== 2 && <SortRounded />}
{sortType === 1 && <AccessTimeRounded />}
{sortType === 2 && <SortByAlphaRounded />}
</IconButton>
<IconButton <Tooltip>
size="small" <TooltipTrigger asChild>
color="inherit" <Button
title={t("Delay check URL")} variant="ghost"
onClick={() => size="icon"
onHeadState({ textState: textState === "url" ? null : "url" }) onClick={() =>
} onHeadState({
> sortType: ((sortType + 1) % 3) as ProxySortType,
{textState === "url" ? ( })
<WifiTetheringRounded /> }
) : ( className="h-8 w-8"
<WifiTetheringOffRounded /> >
)} {sortType === 0 && <ArrowUpDown className="h-5 w-5" />}
</IconButton> {sortType === 1 && <Timer className="h-5 w-5" />}
{sortType === 2 && <ArrowDownAZ className="h-5 w-5" />}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{
[t("Sort by default"), t("Sort by delay"), t("Sort by name")][
sortType
]
}
</p>
</TooltipContent>
</Tooltip>
<IconButton {/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
size="small" <Tooltip>
color="inherit" <TooltipTrigger asChild>
title={showType ? t("Proxy basic") : t("Proxy detail")} <Button
onClick={() => onHeadState({ showType: !showType })} variant="ghost"
> size="icon"
{showType ? <VisibilityRounded /> : <VisibilityOffRounded />} onClick={() => onHeadState({ showType: !showType })}
</IconButton> className="h-8 w-8"
>
{/* Теперь цвет иконки меняется в зависимости от состояния showType */}
<Eye className={cn("h-5 w-5", showType && "text-primary")} />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{showType ? t("Show Basic Info") : t("Show Detailed Info")}</p>
</TooltipContent>
</Tooltip>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</div>
<IconButton <div className="flex items-center gap-1">
size="small" <Tooltip>
color="inherit" <TooltipTrigger asChild>
title={t("Filter")} <Button
onClick={() => variant={getToggleVariant(textState === "url")}
onHeadState({ textState: textState === "filter" ? null : "filter" }) size="icon"
} onClick={() =>
> onHeadState({ textState: textState === "url" ? null : "url" })
{textState === "filter" ? ( }
<FilterAltRounded /> className="h-8 w-8"
) : ( >
<FilterAltOffRounded /> <Wifi className="h-5 w-5" />
)} </Button>
</IconButton> </TooltipTrigger>
<TooltipContent>
<p>{t("Set Latency Test URL")}</p>
</TooltipContent>
</Tooltip>
{textState === "filter" && ( <Tooltip>
<TextField <TooltipTrigger asChild>
autoComplete="new-password" <Button
autoFocus={autoFocus} variant={getToggleVariant(textState === "filter")}
hiddenLabel size="icon"
value={filterText} onClick={() =>
size="small" onHeadState({
variant="outlined" textState: textState === "filter" ? null : "filter",
placeholder={t("Filter conditions")} })
onChange={(e) => onHeadState({ filterText: e.target.value })} }
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} className="h-8 w-8"
/> >
)} <Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Filter by Name")}</p>
</TooltipContent>
</Tooltip>
{textState === "url" && ( <div
<TextField className={cn(
autoComplete="new-password" "transition-all duration-300 ease-in-out",
autoFocus={autoFocus} textState ? "w-48 ml-2" : "w-0",
hiddenLabel )}
autoSave="off" >
value={testUrl} {textState === "filter" && (
size="small" <Input
variant="outlined" autoFocus={autoFocus}
placeholder={t("Delay check URL")} value={filterText}
onChange={(e) => onHeadState({ testUrl: e.target.value })} placeholder={t("Filter conditions")}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }} onChange={(e) => onHeadState({ filterText: e.target.value })}
/> className="h-8"
)} />
</Box> )}
{textState === "url" && (
<Input
autoFocus={autoFocus}
value={testUrl}
placeholder={t("Delay check URL")}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
className="h-8"
/>
)}
</div>
</div>
</div>
</TooltipProvider>
); );
}; };

View File

@@ -1,11 +1,14 @@
// ProxyItemMini.tsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import { alpha, Box, ListItemButton, styled, Typography } from "@mui/material";
import { BaseLoading } from "@/components/base";
import delayManager from "@/services/delay";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CheckCircle2, RefreshCw } from "lucide-react";
import { BaseLoading } from "@/components/base";
import { Badge } from "@/components/ui/badge";
import { cn } from "@root/lib/utils";
interface Props { interface Props {
group: IProxyGroupItem; group: IProxyGroupItem;
@@ -15,16 +18,20 @@ interface Props {
onClick?: (name: string) => void; onClick?: (name: string) => void;
} }
// 多列布局 const getDelayColorClass = (delay: number): string => {
if (delay < 0 || delay >= 10000) return "text-destructive";
if (delay >= 500) return "text-destructive";
if (delay >= 200) return "text-yellow-500";
return "text-green-500";
};
export const ProxyItemMini = (props: Props) => { export const ProxyItemMini = (props: Props) => {
const { group, proxy, selected, showType = true, onClick } = props; const { group, proxy, selected, showType = true, onClick } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
const isPreset = presetList.includes(proxy.name); const isPreset = presetList.includes(proxy.name);
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1); const [delay, setDelay] = useState(-1);
const { verge } = useVerge(); const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000; const timeout = verge?.default_latency_timeout || 10000;
@@ -32,205 +39,97 @@ export const ProxyItemMini = (props: Props) => {
useEffect(() => { useEffect(() => {
if (isPreset) return; if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelay); delayManager.setListener(proxy.name, group.name, setDelay);
return () => delayManager.removeListener(proxy.name, group.name);
return () => { }, [proxy.name, group.name, isPreset]);
delayManager.removeListener(proxy.name, group.name);
};
}, [proxy.name, group.name]);
useEffect(() => { useEffect(() => {
if (!proxy) return; if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, group.name)); setDelay(delayManager.getDelayFix(proxy, group.name));
}, [proxy]); }, [proxy, group.name]);
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
setDelay(-2); setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout)); setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout));
}); });
return ( const handleItemClick = () => onClick?.(proxy.name);
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{
height: 56,
borderRadius: 1.5,
pl: 1.5,
pr: 1,
justifyContent: "space-between",
alignItems: "center",
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const showDelay = delay > 0;
const selectColor = mode === "light" ? primary.main : primary.light;
return { const handleDelayClick = (e: React.MouseEvent) => {
"&:hover .the-check": { display: !showDelay ? "block" : "none" }, e.stopPropagation();
"&:hover .the-delay": { display: showDelay ? "block" : "none" }, if (!proxy.provider) onDelay();
"&:hover .the-icon": { display: "none" }, };
"& .the-pin, & .the-unpin": {
position: "absolute", return (
fontSize: "12px", // --- НАЧАЛО ИЗМЕНЕНИЙ ---
top: "-5px", // Увеличиваем высоту (h-16) и внутренние отступы (p-3)
right: "-5px", <div
}, data-selected={selected}
"& .the-unpin": { filter: "grayscale(1)" }, onClick={handleItemClick}
"&.Mui-selected": { title={`${proxy.name}\n${proxy.now ?? ""}`}
width: `calc(100% + 3px)`, className="group relative flex h-16 cursor-pointer items-center justify-between rounded-lg border border-transparent bg-card p-3 transition-all duration-200 data-[selected=true]:border-primary data-[selected=true]:bg-accent"
marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`,
bgcolor:
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35),
},
backgroundColor: bgcolor,
};
},
]}
> >
<Box {/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
title={`${proxy.name}\n${proxy.now ?? ""}`} <div className="flex-1 min-w-0">
sx={{ overflow: "hidden" }} <p className="truncate text-sm font-medium">{proxy.name}</p>
>
<Typography
variant="body2"
component="div"
color="text.primary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{proxy.name}
</Typography>
{showType && ( {showType && (
<Box <div className="mt-1.5 flex items-center gap-1.5 overflow-hidden">
sx={{
display: "flex",
flexWrap: "nowrap",
flex: "none",
marginTop: "4px",
}}
>
{proxy.now && ( {proxy.now && (
<Typography <span className="truncate text-xs text-muted-foreground">
variant="body2"
component="div"
color="text.secondary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
marginRight: "8px",
}}
>
{proxy.now} {proxy.now}
</Typography> </span>
)} )}
{!!proxy.provider && ( {!!proxy.provider && (
<TypeBox color="text.secondary" component="span"> <Badge variant="outline" className="flex-shrink-0">
{proxy.provider} {proxy.provider}
</TypeBox> </Badge>
)} )}
<TypeBox color="text.secondary" component="span"> <Badge variant="outline" className="flex-shrink-0">
{proxy.type} {proxy.type}
</TypeBox> </Badge>
{proxy.udp && ( {proxy.udp && (
<TypeBox color="text.secondary" component="span"> <Badge variant="outline" className="flex-shrink-0">
UDP UDP
</TypeBox> </Badge>
)} )}
{proxy.xudp && ( </div>
<TypeBox color="text.secondary" component="span">
XUDP
</TypeBox>
)}
{proxy.tfo && (
<TypeBox color="text.secondary" component="span">
TFO
</TypeBox>
)}
{proxy.mptcp && (
<TypeBox color="text.secondary" component="span">
MPTCP
</TypeBox>
)}
{proxy.smux && (
<TypeBox color="text.secondary" component="span">
SMUX
</TypeBox>
)}
</Box>
)}
</Box>
<Box
sx={{ ml: 0.5, color: "primary.main", display: isPreset ? "none" : "" }}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{!proxy.provider && delay !== -2 && (
// provider的节点不支持检测
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
display: "none", // hover才显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)} )}
</div>
{delay > 0 && ( <div className="ml-2 flex h-6 w-14 items-center justify-end text-sm">
// 显示延迟 {isPreset ? null : delay === -2 ? (
<Widget <div className="flex items-center text-muted-foreground">
className="the-delay" <BaseLoading className="h-4 w-4" />
onClick={(e) => { </div>
if (proxy.provider) return; ) : delay > 0 ? (
e.preventDefault(); <div
e.stopPropagation(); onClick={handleDelayClick}
onDelay(); className={`font-medium ${getDelayColorClass(delay)} ${!proxy.provider ? "hover:opacity-70" : "cursor-default"}`}
}}
color={delayManager.formatDelayColor(delay, timeout)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
> >
{delayManager.formatDelay(delay, timeout)} {delayManager.formatDelay(delay, timeout)}
</Widget> </div>
) : (
<>
{selected && (
<CheckCircle2 className="h-5 w-5 text-primary group-hover:hidden" />
)}
{!selected && !proxy.provider && (
<div
onClick={handleDelayClick}
className="hidden h-full w-full items-center justify-center rounded-md text-muted-foreground hover:bg-primary/10 group-hover:flex"
>
<RefreshCw className="h-4 w-4" />
</div>
)}
</>
)} )}
{delay !== -2 && delay <= 0 && selected && ( </div>
// 展示已选择的icon
<CheckCircleOutlineRounded {group.fixed === proxy.name && (
className="the-icon"
sx={{ fontSize: 16, mr: 0.5, display: "block" }}
/>
)}
</Box>
{group.fixed && group.fixed === proxy.name && (
// 展示fixed状态
<span <span
className={proxy.name === group.now ? "the-pin" : "the-unpin"} className={cn("absolute -top-1 -right-1 text-base", {
grayscale: proxy.name !== group.now,
})}
title={ title={
group.type === "URLTest" ? t("Delay check to cancel fixed") : "" group.type === "URLTest" ? t("Delay check to cancel fixed") : ""
} }
@@ -238,29 +137,6 @@ export const ProxyItemMini = (props: Props) => {
📌 📌
</span> </span>
)} )}
</ListItemButton> </div>
); );
}; };
const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "2px 4px",
fontSize: 14,
fontFamily: typography.fontFamily,
borderRadius: "4px",
}));
const TypeBox = styled(Box, {
shouldForwardProp: (prop) => prop !== "component",
})<{ component?: React.ElementType }>(({ theme: { palette, typography } }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: "text.secondary",
color: "text.secondary",
borderRadius: 4,
fontSize: 10,
fontFamily: typography.fontFamily,
marginRight: "4px",
marginTop: "auto",
padding: "0 4px",
lineHeight: 1.5,
}));

View File

@@ -1,199 +1,134 @@
// ProxyItem.tsx
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import {
alpha,
Box,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
styled,
SxProps,
Theme,
} from "@mui/material";
import { BaseLoading } from "@/components/base";
import delayManager from "@/services/delay";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import delayManager from "@/services/delay";
// Новые импорты
import { CheckCircle2, RefreshCw } from "lucide-react";
import { BaseLoading } from "@/components/base";
import { Badge } from "@/components/ui/badge";
interface Props { interface Props {
group: IProxyGroupItem; group: IProxyGroupItem;
proxy: IProxyItem; proxy: IProxyItem;
selected: boolean; selected: boolean;
showType?: boolean; showType?: boolean;
sx?: SxProps<Theme>;
onClick?: (name: string) => void; onClick?: (name: string) => void;
} }
const Widget = styled(Box)(() => ({ // Вспомогательная функция для определения цвета задержки
padding: "3px 6px", const getDelayColorClass = (delay: number): string => {
fontSize: 14, if (delay < 0 || delay >= 10000) return "text-destructive";
borderRadius: "4px", if (delay >= 500) return "text-destructive";
})); if (delay >= 200) return "text-yellow-500";
return "text-green-500";
const TypeBox = styled("span")(({ theme }) => ({ };
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.text.secondary, 0.36),
color: alpha(theme.palette.text.secondary, 0.42),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
export const ProxyItem = (props: Props) => { export const ProxyItem = (props: Props) => {
const { group, proxy, selected, showType = true, sx, onClick } = props; const { group, proxy, selected, showType = true, onClick } = props;
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"]; const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
const isPreset = presetList.includes(proxy.name); const isPreset = presetList.includes(proxy.name);
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1); const [delay, setDelay] = useState(-1);
const { verge } = useVerge(); const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000; const timeout = verge?.default_latency_timeout || 10000;
// Вся логика хуков остается без изменений
useEffect(() => { useEffect(() => {
if (isPreset) return; if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelay); delayManager.setListener(proxy.name, group.name, setDelay);
return () => { return () => {
delayManager.removeListener(proxy.name, group.name); delayManager.removeListener(proxy.name, group.name);
}; };
}, [proxy.name, group.name]); }, [proxy.name, group.name, isPreset]);
useEffect(() => { useEffect(() => {
if (!proxy) return; if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, group.name)); setDelay(delayManager.getDelayFix(proxy, group.name));
}, [proxy]); }, [proxy, group.name]);
const onDelay = useLockFn(async () => { const onDelay = useLockFn(async () => {
setDelay(-2); setDelay(-2); // -2 это состояние загрузки
setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout)); const newDelay = await delayManager.checkDelay(
proxy.name,
group.name,
timeout,
);
setDelay(newDelay);
}); });
const handleItemClick = () => {
if (onClick) {
onClick(proxy.name);
}
};
const handleDelayClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Останавливаем всплытие, чтобы не сработал клик по всей строке
if (!proxy.provider) {
onDelay();
}
};
return ( return (
<ListItem sx={sx}> // 1. Основной контейнер. Добавляем `group` для hover-эффектов на дочерних элементах.
<ListItemButton // Атрибут data-selected используется для стилизации выделенного элемента.
dense <div
selected={selected} data-selected={selected}
onClick={() => onClick?.(proxy.name)} onClick={handleItemClick}
sx={[ className="group mx-2 mb-2 flex cursor-pointer items-center rounded-lg border border-transparent bg-card p-2 pr-3 transition-all duration-200 data-[selected=true]:border-primary data-[selected=true]:bg-accent"
{ borderRadius: 1 }, >
({ palette: { mode, primary } }) => { {/* Левая часть с названием и тегами */}
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; <div className="flex-1 min-w-0">
const selectColor = mode === "light" ? primary.main : primary.light; <p className="truncate font-medium text-sm">{proxy.name}</p>
const showDelay = delay > 0; {showType && (
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{!!proxy.provider && (
<Badge variant="outline">{proxy.provider}</Badge>
)}
<Badge variant="outline">{proxy.type}</Badge>
{proxy.udp && <Badge variant="outline">UDP</Badge>}
{proxy.xudp && <Badge variant="outline">XUDP</Badge>}
{proxy.tfo && <Badge variant="outline">TFO</Badge>}
{proxy.mptcp && <Badge variant="outline">MPTCP</Badge>}
{proxy.smux && <Badge variant="outline">SMUX</Badge>}
</div>
)}
</div>
return { {/* Правая часть с индикатором задержки */}
"&:hover .the-check": { display: !showDelay ? "block" : "none" }, <div className="ml-4 flex h-6 w-20 items-center justify-end text-sm">
"&:hover .the-delay": { display: showDelay ? "block" : "none" }, {isPreset ? null : delay === -2 ? ( // Состояние загрузки
"&:hover .the-icon": { display: "none" }, <div className="flex items-center text-muted-foreground">
"&.Mui-selected": { <BaseLoading className="w-4 h-4" />
width: `calc(100% + 3px)`, </div>
marginLeft: `-3px`, ) : delay > 0 ? ( // Состояние с задержкой
borderLeft: `3px solid ${selectColor}`, <div
bgcolor: onClick={handleDelayClick}
mode === "light" className={`font-medium ${getDelayColorClass(delay)} ${!proxy.provider ? "hover:opacity-70" : "cursor-default"}`}
? alpha(primary.main, 0.15) >
: alpha(primary.main, 0.35), {delayManager.formatDelay(delay, timeout)} ms
}, </div>
backgroundColor: bgcolor, ) : (
marginBottom: "8px", // Состояние по умолчанию (до проверки)
height: "40px", <>
}; {selected && (
}, <CheckCircle2 className="h-5 w-5 text-primary group-hover:hidden" />
]} )}
> {!selected && !proxy.provider && (
<ListItemText <div
title={proxy.name} onClick={handleDelayClick}
secondary={ className="hidden h-full w-full items-center justify-center rounded-md text-muted-foreground hover:bg-primary/10 group-hover:flex"
<>
<Box
sx={{
display: "inline-block",
marginRight: "8px",
fontSize: "14px",
color: "text.primary",
}}
> >
{proxy.name} <RefreshCw className="h-4 w-4" />
{showType && proxy.now && ` - ${proxy.now}`} </div>
</Box> )}
{showType && !!proxy.provider && ( </>
<TypeBox>{proxy.provider}</TypeBox> )}
)} </div>
{showType && <TypeBox>{proxy.type}</TypeBox>} </div>
{showType && proxy.udp && <TypeBox>UDP</TypeBox>}
{showType && proxy.xudp && <TypeBox>XUDP</TypeBox>}
{showType && proxy.tfo && <TypeBox>TFO</TypeBox>}
{showType && proxy.mptcp && <TypeBox>MPTCP</TypeBox>}
{showType && proxy.smux && <TypeBox>SMUX</TypeBox>}
</>
}
/>
<ListItemIcon
sx={{
justifyContent: "flex-end",
color: "primary.main",
display: isPreset ? "none" : "",
}}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{!proxy.provider && delay !== -2 && (
// provider的节点不支持检测
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
display: "none", // hover才显示
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
Check
</Widget>
)}
{delay > 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
if (proxy.provider) return;
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={delayManager.formatDelayColor(delay, timeout)}
sx={({ palette }) =>
!proxy.provider
? { ":hover": { bgcolor: alpha(palette.primary.main, 0.15) } }
: {}
}
>
{delayManager.formatDelay(delay, timeout)}
</Widget>
)}
{delay !== -2 && delay <= 0 && selected && (
// 展示已选择的icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16 }}
/>
)}
</ListItemIcon>
</ListItemButton>
</ListItem>
); );
}; };

View File

@@ -1,29 +1,23 @@
import { // ProxyRender.tsx
alpha,
Box, import { useMemo } from "react";
ListItemText, import { useTranslation } from "react-i18next";
ListItemButton,
Typography,
styled,
Chip,
Tooltip,
} from "@mui/material";
import {
ExpandLessRounded,
ExpandMoreRounded,
InboxRounded,
} from "@mui/icons-material";
import { HeadState } from "./use-head-state"; import { HeadState } from "./use-head-state";
import { ProxyHead } from "./proxy-head"; import { ProxyHead } from "./proxy-head";
import { ProxyItem } from "./proxy-item"; import { ProxyItem } from "./proxy-item";
import { ProxyItemMini } from "./proxy-item-mini"; import { ProxyItemMini } from "./proxy-item-mini";
import type { IRenderItem } from "./use-render-list"; import type { IRenderItem } from "./use-render-list";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useThemeMode } from "@/services/states";
import { useEffect, useMemo, useState } from "react"; // Новые импорты из lucide-react и shadcn/ui
import { convertFileSrc } from "@tauri-apps/api/core"; import { ChevronDown, ChevronUp, Inbox } from "lucide-react";
import { downloadIconCache } from "@/services/cmds"; import { Badge } from "@/components/ui/badge";
import { useTranslation } from "react-i18next"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface RenderProps { interface RenderProps {
item: IRenderItem; item: IRenderItem;
@@ -44,125 +38,85 @@ export const ProxyRender = (props: RenderProps) => {
const { type, group, headState, proxy, proxyCol } = item; const { type, group, headState, proxy, proxyCol } = item;
const { verge } = useVerge(); const { verge } = useVerge();
const enable_group_icon = verge?.enable_group_icon ?? true; const enable_group_icon = verge?.enable_group_icon ?? true;
const mode = useThemeMode(); // Логика с иконками остается, но ее нужно будет адаптировать, если она тоже использует MUI
const isDark = mode === "light" ? false : true; // В данном рефакторинге мы предполагаем, что иконки - это просто URL или SVG-строки
const itembackgroundcolor = isDark ? "#282A36" : "#ffffff";
const [iconCachePath, setIconCachePath] = useState("");
useEffect(() => {
initIconCachePath();
}, [group]);
async function initIconCachePath() {
if (group.icon && group.icon.trim().startsWith("http")) {
const fileName =
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
const iconPath = await downloadIconCache(group.icon, fileName);
setIconCachePath(convertFileSrc(iconPath));
}
}
function getFileName(url: string) {
return url.substring(url.lastIndexOf("/") + 1);
}
// Рендер заголовка группы (type 0)
if (type === 0) { if (type === 0) {
return ( return (
<ListItemButton <div
dense className="flex items-center mx-2 my-1 p-3 rounded-lg bg-card hover:bg-accent cursor-pointer transition-colors"
style={{
background: itembackgroundcolor,
height: "100%",
margin: "8px 8px",
borderRadius: "8px",
}}
onClick={() => onHeadState(group.name, { open: !headState?.open })} onClick={() => onHeadState(group.name, { open: !headState?.open })}
> >
{enable_group_icon && {/* Логика иконок групп (сохранена) */}
group.icon && {enable_group_icon && group.icon && (
group.icon.trim().startsWith("http") && ( <img
<img src={
src={iconCachePath === "" ? group.icon : iconCachePath} group.icon.startsWith("data")
width="32px" ? group.icon
style={{ marginRight: "12px", borderRadius: "6px" }} : group.icon.startsWith("<svg")
/> ? `data:image/svg+xml;base64,${btoa(group.icon)}`
: group.icon
}
className="w-8 h-8 mr-3 rounded-md"
alt={group.name}
/>
)}
{/* Основная текстовая часть */}
<div className="flex-1 min-w-0">
<p className="text-base font-semibold truncate">{group.name}</p>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<Badge variant="outline" className="mr-2">
{group.type}
</Badge>
<span className="truncate">{group.now}</span>
</div>
</div>
{/* Правая часть с количеством и иконкой */}
<div className="flex items-center ml-2">
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary" className="mr-2">
{group.all.length}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{t("Proxy Count")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{headState?.open ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)} )}
{enable_group_icon && </div>
group.icon && </div>
group.icon.trim().startsWith("data") && (
<img
src={group.icon}
width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }}
/>
)}
{enable_group_icon &&
group.icon &&
group.icon.trim().startsWith("<svg") && (
<img
src={`data:image/svg+xml;base64,${btoa(group.icon)}`}
width="32px"
/>
)}
<ListItemText
primary={<StyledPrimary>{group.name}</StyledPrimary>}
secondary={
<Box
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
}}
>
<Box component="span" sx={{ marginTop: "2px" }}>
<StyledTypeBox>{group.type}</StyledTypeBox>
<StyledSubtitle sx={{ color: "text.secondary" }}>
{group.now}
</StyledSubtitle>
</Box>
</Box>
}
slotProps={{
secondary: {
component: "div",
sx: { display: "flex", alignItems: "center", color: "#ccc" },
},
}}
/>
<Box sx={{ display: "flex", alignItems: "center" }}>
<Tooltip title={t("Proxy Count")} arrow>
<Chip
size="small"
label={`${group.all.length}`}
sx={{
mr: 1,
backgroundColor: (theme) =>
alpha(theme.palette.primary.main, 0.1),
color: (theme) => theme.palette.primary.main,
}}
/>
</Tooltip>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</Box>
</ListItemButton>
); );
} }
// Рендер шапки с кнопками управления группой (type 1)
// Компонент ProxyHead не меняем, только его контейнер
if (type === 1) { if (type === 1) {
return ( return (
<ProxyHead <div className={indent ? "mt-1" : "mt-0.5"}>
sx={{ pl: 2, pr: 3, mt: indent ? 1 : 0.5, mb: 1 }} <ProxyHead
url={group.testUrl} url={group.testUrl}
groupName={group.name} groupName={group.name}
headState={headState!} headState={headState!}
onLocation={() => onLocation(group)} onLocation={() => onLocation(group)}
onCheckDelay={() => onCheckAll(group.name)} onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)} onHeadState={(p) => onHeadState(group.name, p)}
/> />
</div>
); );
} }
// Рендер полного элемента прокси (type 2)
// Компонент ProxyItem не меняем
if (type === 2) { if (type === 2) {
return ( return (
<ProxyItem <ProxyItem
@@ -170,87 +124,45 @@ export const ProxyRender = (props: RenderProps) => {
proxy={proxy!} proxy={proxy!}
selected={group.now === proxy?.name} selected={group.now === proxy?.name}
showType={headState?.showType} showType={headState?.showType}
sx={{ py: 0, pl: 2 }}
onClick={() => onChangeProxy(group, proxy!)} onClick={() => onChangeProxy(group, proxy!)}
/> />
); );
} }
// Рендер заглушки "No Proxies" (type 3)
if (type === 3) { if (type === 3) {
return ( return (
<Box <div className="flex flex-col items-center justify-center p-4 text-muted-foreground">
sx={{ <Inbox className="w-12 h-12" />
py: 2, <p>No Proxies</p>
pl: 0, </div>
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography>
</Box>
); );
} }
// Рендер сетки мини-прокси (type 4)
if (type === 4) { if (type === 4) {
const proxyColItemsMemo = useMemo(() => { const proxyColItemsMemo = useMemo(() => {
return proxyCol?.map((proxy) => ( return proxyCol?.map((p) => (
<ProxyItemMini <ProxyItemMini
key={item.key + proxy.name} key={item.key + p.name}
group={group} group={group}
proxy={proxy!} proxy={p}
selected={group.now === proxy.name} selected={group.now === p.name}
showType={headState?.showType} showType={headState?.showType}
onClick={() => onChangeProxy(group, proxy!)} onClick={() => onChangeProxy(group, p)}
/> />
)); ));
}, [proxyCol, group, headState]); }, [proxyCol, group, headState, item.key, onChangeProxy]);
return ( return (
<Box <div
sx={{ className="grid gap-2 p-2"
height: 56, style={{ gridTemplateColumns: `repeat(${item.col || 2}, 1fr)` }}
display: "grid",
gap: 1,
pl: 2,
pr: 2,
pb: 1,
gridTemplateColumns: `repeat(${item.col! || 2}, 1fr)`,
}}
> >
{proxyColItemsMemo} {proxyColItemsMemo}
</Box> </div>
); );
} }
return null; return null;
}; };
const StyledPrimary = styled("span")`
font-size: 16px;
font-weight: 700;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledSubtitle = styled("span")`
font-size: 13px;
overflow: hidden;
color: text.secondary;
text-overflow: ellipsis;
white-space: nowrap;
`;
const StyledTypeBox = styled(Box)(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.primary.main, 0.5),
color: alpha(theme.palette.primary.main, 0.8),
borderRadius: 4,
fontSize: 10,
padding: "0 4px",
lineHeight: 1.5,
marginRight: "8px",
}));

View File

@@ -1,30 +1,36 @@
import { useState } from "react"; import { useState } from "react";
import {
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
List,
ListItem,
ListItemText,
Typography,
Divider,
alpha,
styled,
useTheme,
} from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { ruleProviderUpdate } from "@/services/api";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import { useAppData } from "@/providers/app-data-provider";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { useAppData } from "@/providers/app-data-provider";
import { ruleProviderUpdate } from "@/services/api";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// 定义规则提供者类型 // Компоненты shadcn/ui
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
// Иконки
import { Database, RefreshCw } from "lucide-react";
import { cn } from "@root/lib/utils";
// Интерфейс для провайдера (взят из вашего файла)
interface RuleProviderItem { interface RuleProviderItem {
behavior: string; behavior: string;
ruleCount: number; ruleCount: number;
@@ -32,250 +38,153 @@ interface RuleProviderItem {
vehicleType: string; vehicleType: string;
} }
// 辅助组件 - 类型框
const TypeBox = styled(Box)<{ component?: React.ElementType }>(({ theme }) => ({
display: "inline-block",
border: "1px solid #ccc",
borderColor: alpha(theme.palette.secondary.main, 0.5),
color: alpha(theme.palette.secondary.main, 0.8),
borderRadius: 4,
fontSize: 10,
marginRight: "4px",
padding: "0 2px",
lineHeight: 1.25,
}));
export const ProviderButton = () => { export const ProviderButton = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData(); const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({}); const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者 const hasProviders = ruleProviders && Object.keys(ruleProviders).length > 0;
const hasProviders = Object.keys(ruleProviders || {}).length > 0;
// 更新单个规则提供者
const updateProvider = useLockFn(async (name: string) => { const updateProvider = useLockFn(async (name: string) => {
try { try {
// 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true })); setUpdating((prev) => ({ ...prev, [name]: true }));
await ruleProviderUpdate(name); await ruleProviderUpdate(name);
// 刷新数据
await refreshRules(); await refreshRules();
await refreshRuleProviders(); await refreshRuleProviders();
showNotice("success", `${name} ${t("Update Successful")}`);
showNotice("success", `${name} 更新成功`);
} catch (err: any) { } catch (err: any) {
showNotice( showNotice(
"error", "error",
`${name} 更新失败: ${err?.message || err.toString()}`, `${name} ${t("Update Failed")}: ${err?.message || err.toString()}`,
); );
} finally { } finally {
// 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false })); setUpdating((prev) => ({ ...prev, [name]: false }));
} }
}); });
// 更新所有规则提供者
const updateAllProviders = useLockFn(async () => { const updateAllProviders = useLockFn(async () => {
try { const allProviders = Object.keys(ruleProviders || {});
// 获取所有provider的名称 if (allProviders.length === 0) return;
const allProviders = Object.keys(ruleProviders || {});
if (allProviders.length === 0) { const newUpdating = allProviders.reduce(
showNotice("info", "没有可更新的规则提供者"); (acc, key) => ({ ...acc, [key]: true }),
return; {},
);
setUpdating(newUpdating);
for (const name of allProviders) {
try {
await ruleProviderUpdate(name);
} catch (err) {
console.error(`Failed to update ${name}`, err);
} }
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce(
(acc, key) => {
acc[key] = true;
return acc;
},
{} as Record<string, boolean>,
);
setUpdating(newUpdating);
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await ruleProviderUpdate(name);
// 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false }));
} catch (err) {
console.error(`更新 ${name} 失败`, err);
// 继续执行下一个,不中断整体流程
}
}
// 刷新数据
await refreshRules();
await refreshRuleProviders();
showNotice("success", "全部规则提供者更新成功");
} catch (err: any) {
showNotice("error", `更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
} }
});
const handleClose = () => { await refreshRules();
setOpen(false); await refreshRuleProviders();
}; setUpdating({});
showNotice("success", t("All Rule Providers Updated"));
});
if (!hasProviders) return null; if (!hasProviders) return null;
return ( return (
<> <Dialog open={open} onOpenChange={setOpen}>
<Button <TooltipProvider>
variant="outlined" <Tooltip>
size="small" <TooltipTrigger asChild>
startIcon={<StorageOutlined />} <DialogTrigger asChild>
onClick={() => setOpen(true)} <Button variant="ghost" size="icon">
> <Database className="h-5 w-5" />
{t("Rule Provider")} </Button>
</Button> </DialogTrigger>
</TooltipTrigger>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth> <TooltipContent>
<DialogTitle> <p>{t("Rule Provider")}</p>
<Box </TooltipContent>
display="flex" </Tooltip>
justifyContent="space-between" </TooltipProvider>
alignItems="center" <DialogContent className="max-w-2xl">
> <DialogHeader>
<Typography variant="h6">{t("Rule Providers")}</Typography> {/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
<Button {/* Убираем justify-between и используем gap для отступа */}
variant="contained" <div className="flex items-center gap-4">
size="small" <DialogTitle>{t("Rule Providers")}</DialogTitle>
onClick={updateAllProviders} <Button size="sm" onClick={updateAllProviders}>
>
{t("Update All")} {t("Update All")}
</Button> </Button>
</Box> </div>
</DialogTitle> {/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</DialogHeader>
<DialogContent> <div className="max-h-[60vh] overflow-y-auto -mx-6 px-6 py-4 space-y-2">
<List sx={{ py: 0, minHeight: 250 }}> {Object.entries(ruleProviders || {}).map(([key, item]) => {
{Object.entries(ruleProviders || {}).map(([key, item]) => { const provider = item as RuleProviderItem;
const provider = item as RuleProviderItem; const time = dayjs(provider.updatedAt);
const time = dayjs(provider.updatedAt); const isUpdating = updating[key];
const isUpdating = updating[key];
return ( return (
<ListItem <div
key={key} key={key}
sx={[ className="flex items-center rounded-lg border bg-card p-3"
{ >
p: 0, <div className="flex-1 min-w-0">
mb: "8px", <div className="flex justify-between items-center">
borderRadius: 2, <div className="flex items-center gap-2">
overflow: "hidden", <p className="font-semibold truncate" title={key}>
transition: "all 0.2s", {key}
}, </p>
({ palette: { mode, primary } }) => { <Badge variant="secondary">{provider.ruleCount}</Badge>
const bgcolor = mode === "light" ? "#ffffff" : "#24252f"; </div>
const hoverColor = <p
mode === "light" className="text-xs text-muted-foreground"
? alpha(primary.main, 0.1) title={time.format("YYYY-MM-DD HH:mm:ss")}
: alpha(primary.main, 0.2);
return {
backgroundColor: bgcolor,
"&:hover": {
backgroundColor: hoverColor,
borderColor: alpha(primary.main, 0.3),
},
};
},
]}
>
<ListItemText
sx={{ px: 2, py: 1 }}
primary={
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography
variant="subtitle1"
component="div"
noWrap
title={key}
sx={{ display: "flex", alignItems: "center" }}
>
<span style={{ marginRight: "8px" }}>{key}</span>
<TypeBox component="span">
{provider.ruleCount}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<Box sx={{ display: "flex" }}>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
<TypeBox component="span">{provider.behavior}</TypeBox>
</Box>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
size="small"
color="primary"
onClick={() => updateProvider(key)}
disabled={isUpdating}
sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
title={t("Update Provider") as string}
> >
<RefreshRounded /> {t("Update At")}: {time.fromNow()}
</IconButton> </p>
</Box> </div>
</ListItem> <div className="flex items-center gap-2 mt-2">
); <Badge variant="outline">{provider.vehicleType}</Badge>
})} <Badge variant="outline">{provider.behavior}</Badge>
</List> </div>
</DialogContent> </div>
<DialogActions> <Separator orientation="vertical" className="h-8 mx-4" />
<Button onClick={handleClose} variant="outlined">
{t("Close")} <TooltipProvider>
</Button> <Tooltip>
</DialogActions> <TooltipTrigger asChild>
</Dialog> <Button
</> variant="ghost"
size="icon"
onClick={() => updateProvider(key)}
disabled={isUpdating}
>
<RefreshCw
className={cn(
"h-5 w-5",
isUpdating && "animate-spin",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Update Provider")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
})}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}; };

View File

@@ -1,71 +1,65 @@
import { styled, Box, Typography } from "@mui/material"; // RuleItem.tsx
const Item = styled(Box)(({ theme }) => ({ import { cn } from "@root/lib/utils"; // Импортируем утилиту для классов
display: "flex",
padding: "4px 16px",
color: theme.palette.text.primary,
}));
const COLOR = [ // Массив CSS-классов для раскрашивания названий прокси
"primary", const PROXY_COLOR_CLASSES = [
"secondary", "text-sky-500",
"info.main", "text-violet-500",
"warning.main", "text-amber-500",
"success.main", "text-lime-500",
"text-emerald-500",
]; ];
// Новая функция для получения CSS-класса цвета на основе названия
const getProxyColorClass = (proxyName: string): string => {
if (proxyName === "REJECT" || proxyName === "REJECT-DROP") {
return "text-destructive"; // Стандартный "опасный" цвет из shadcn
}
if (proxyName === "DIRECT") {
return "text-primary"; // Стандартный основной цвет из shadcn
}
// Хеширующая функция для выбора случайного цвета из массива (логика сохранена)
let sum = 0;
for (let i = 0; i < proxyName.length; i++) {
sum += proxyName.charCodeAt(i);
}
return PROXY_COLOR_CLASSES[sum % PROXY_COLOR_CLASSES.length];
};
interface Props { interface Props {
index: number; index: number;
value: IRuleItem; value: IRuleItem;
} }
const parseColor = (text: string) => {
if (text === "REJECT" || text === "REJECT-DROP") return "error.main";
if (text === "DIRECT") return "text.primary";
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum += text.charCodeAt(i);
}
return COLOR[sum % COLOR.length];
};
const RuleItem = (props: Props) => { const RuleItem = (props: Props) => {
const { index, value } = props; const { index, value } = props;
return ( return (
<Item sx={{ borderBottom: "1px solid var(--divider-color)" }}> // Корневой элемент, стилизованный с помощью Tailwind
<Typography <div className="flex p-4 border-b border-border">
color="text.secondary" {/* Номер правила */}
variant="body2" <p className="w-10 text-center text-sm text-muted-foreground mr-4 pt-0.5">
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: "center" }}
>
{index} {index}
</Typography> </p>
<Box sx={{ userSelect: "text" }}> {/* Основной контент */}
<Typography component="h6" variant="subtitle1" color="text.primary"> <div className="flex-1">
{/* Полезная нагрузка (условие правила) */}
<p className="font-semibold text-sm break-all">
{value.payload || "-"} {value.payload || "-"}
</Typography> </p>
<Typography {/* Нижняя строка с типом правила и названием прокси */}
component="span" <div className="flex items-center text-xs mt-1.5">
variant="body2" <p className="text-muted-foreground w-32 mr-4">{value.type}</p>
color="text.secondary" <p className={cn("font-medium", getProxyColorClass(value.proxy))}>
sx={{ mr: 3, minWidth: 120, display: "inline-block" }} {value.proxy}
> </p>
{value.type} </div>
</Typography> </div>
</div>
<Typography
component="span"
variant="body2"
color={parseColor(value.proxy)}
>
{value.proxy}
</Typography>
</Box>
</Item>
); );
}; };

View File

@@ -4,18 +4,16 @@ import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { isValidUrl } from "@/utils/helper"; import { isValidUrl } from "@/utils/helper";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import {
TextField, // Новые импорты
Button,
Grid,
Stack,
IconButton,
InputAdornment,
} from "@mui/material";
import Visibility from "@mui/icons-material/Visibility";
import VisibilityOff from "@mui/icons-material/VisibilityOff";
import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds"; import { saveWebdavConfig, createWebdavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Eye, EyeOff } from "lucide-react";
import { cn } from "@root/lib/utils";
export interface BackupConfigViewerProps { export interface BackupConfigViewerProps {
onBackupSuccess: () => Promise<void>; onBackupSuccess: () => Promise<void>;
@@ -26,219 +24,142 @@ export interface BackupConfigViewerProps {
} }
export const BackupConfigViewer = memo( export const BackupConfigViewer = memo(
({ ({ onBackupSuccess, onSaveSuccess, onRefresh, onInit, setLoading }: BackupConfigViewerProps) => {
onBackupSuccess,
onSaveSuccess,
onRefresh,
onInit,
setLoading,
}: BackupConfigViewerProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge } = useVerge(); const { verge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {}; const { webdav_url, webdav_username, webdav_password } = verge || {};
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const urlRef = useRef<HTMLInputElement>(null);
const { register, handleSubmit, watch } = useForm<IWebDavConfig>({ const form = useForm<IWebDavConfig>({
defaultValues: { defaultValues: { url: '', username: '', password: '' },
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
}); });
// Синхронизируем форму с данными из verge
useEffect(() => {
form.reset({
url: webdav_url,
username: webdav_username,
password: webdav_password
});
}, [webdav_url, webdav_username, webdav_password, form.reset]);
const { register, handleSubmit, watch, getValues } = form;
const url = watch("url"); const url = watch("url");
const username = watch("username"); const username = watch("username");
const password = watch("password"); const password = watch("password");
const webdavChanged = const webdavChanged = webdav_url !== url || webdav_username !== username || webdav_password !== password;
webdav_url !== url ||
webdav_username !== username ||
webdav_password !== password;
console.log(
"webdavChanged",
webdavChanged,
webdav_url,
webdav_username,
webdav_password,
);
const handleClickShowPassword = () => {
setShowPassword((prev) => !prev);
};
useEffect(() => {
if (webdav_url && webdav_username && webdav_password) {
onInit();
}
}, []);
const checkForm = () => { const checkForm = () => {
const username = usernameRef.current?.value; const values = getValues();
const password = passwordRef.current?.value; if (!values.url) { showNotice("error", t("WebDAV URL Required")); throw new Error("URL Required"); }
const url = urlRef.current?.value; if (!isValidUrl(values.url)) { showNotice("error", t("Invalid WebDAV URL")); throw new Error("Invalid URL"); }
if (!values.username) { showNotice("error", t("Username Required")); throw new Error("Username Required"); }
if (!url) { if (!values.password) { showNotice("error", t("Password Required")); throw new Error("Password Required"); }
urlRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("WebDAV URL Required"));
} else if (!isValidUrl(url)) {
urlRef.current?.focus();
showNotice("error", t("Invalid WebDAV URL"));
throw new Error(t("Invalid WebDAV URL"));
}
if (!username) {
usernameRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("Username Required"));
}
if (!password) {
passwordRef.current?.focus();
showNotice("error", t("WebDAV URL Required"));
throw new Error(t("Password Required"));
}
}; };
const save = useLockFn(async (data: IWebDavConfig) => { const save = useLockFn(async (data: IWebDavConfig) => {
checkForm(); try { checkForm(); } catch { return; }
try { try {
setLoading(true); setLoading(true);
await saveWebdavConfig( await saveWebdavConfig(data.url.trim(), data.username.trim(), data.password);
data.url.trim(), showNotice("success", t("WebDAV Config Saved"));
data.username.trim(), await onSaveSuccess();
data.password, } catch (error) {
).then(() => { showNotice("error", t("WebDAV Config Save Failed", { error }), 3000);
showNotice("success", t("WebDAV Config Saved")); } finally {
onSaveSuccess(); setLoading(false);
}); }
} catch (error) {
showNotice("error", t("WebDAV Config Save Failed", { error }), 3000);
} finally {
setLoading(false);
}
}); });
const handleBackup = useLockFn(async () => { const handleBackup = useLockFn(async () => {
checkForm(); try { checkForm(); } catch { return; }
try { try {
setLoading(true); setLoading(true);
await createWebdavBackup().then(async () => { await createWebdavBackup();
showNotice("success", t("Backup Created")); showNotice("success", t("Backup Created"));
await onBackupSuccess(); await onBackupSuccess();
}); } catch (error) {
} catch (error) { showNotice("error", t("Backup Failed", { error }));
showNotice("error", t("Backup Failed", { error })); } finally {
} finally { setLoading(false);
setLoading(false); }
}
}); });
return ( return (
<form onSubmit={(e) => e.preventDefault()}> <Form {...form}>
<Grid container spacing={2}> <form onSubmit={e => e.preventDefault()} className="flex flex-col sm:flex-row gap-4">
<Grid size={{ xs: 12, sm: 9 }}> {/* Левая часть: поля ввода */}
<Grid container spacing={2}> <div className="flex-1 space-y-4">
<Grid size={{ xs: 12 }}> <FormField
<TextField control={form.control}
fullWidth name="url"
label={t("WebDAV Server URL")} render={({ field }) => (
variant="outlined" <FormItem>
size="small" <FormLabel>{t("WebDAV Server URL")}</FormLabel>
{...register("url")} <FormControl><Input {...field} /></FormControl>
autoCorrect="off" <FormMessage />
autoCapitalize="off" </FormItem>
spellCheck="false"
inputRef={urlRef}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Password")}
type={showPassword ? "text" : "password"}
variant="outlined"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={passwordRef}
{...register("password")}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={handleClickShowPassword}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
</Grid>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<Stack
direction="column"
justifyContent="space-between"
alignItems="stretch"
sx={{ height: "100%" }}
>
{webdavChanged ||
webdav_url === undefined ||
webdav_username === undefined ||
webdav_password === undefined ? (
<Button
variant="contained"
color={"primary"}
sx={{ height: "100%" }}
type="button"
onClick={handleSubmit(save)}
>
{t("Save")}
</Button>
) : (
<>
<Button
variant="contained"
color="success"
onClick={handleBackup}
type="button"
size="large"
>
{t("Backup")}
</Button>
<Button
variant="outlined"
onClick={onRefresh}
type="button"
size="large"
>
{t("Refresh")}
</Button>
</>
)} )}
</Stack> />
</Grid> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
</Grid> <FormField
</form> control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Username")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Password")}</FormLabel>
<div className="relative">
<FormControl>
<Input type={showPassword ? "text" : "password"} {...field} className="pr-10" />
</FormControl>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-0 right-0 h-full px-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Правая часть: кнопки действий */}
<div className="flex sm:flex-col gap-2">
{webdavChanged || !webdav_url ? (
<Button type="button" className="w-full h-full" onClick={handleSubmit(save)}>
{t("Save")}
</Button>
) : (
<>
<Button type="button" className="w-full" onClick={handleBackup}>
{t("Backup")}
</Button>
<Button type="button" variant="outline" className="w-full" onClick={onRefresh}>
{t("Refresh")}
</Button>
</>
)}
</div>
</form>
</Form>
); );
}, }
); );

View File

@@ -1,33 +1,28 @@
import { SVGProps, memo } from "react"; import { SVGProps, memo } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import dayjs from "dayjs";
import { restartApp } from "@/services/cmds";
import { deleteWebdavBackup, restoreWebDavBackup } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import { import {
Box,
Paper,
IconButton,
Divider,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader,
TableRow, TableRow,
TablePagination, } from "@/components/ui/table";
} from "@mui/material"; import { Button } from "@/components/ui/button";
import { Typography } from "@mui/material"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useLockFn } from "ahooks"; import { Trash2, History } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Dayjs } from "dayjs";
import {
deleteWebdavBackup,
restoreWebDavBackup,
restartApp,
} from "@/services/cmds";
import DeleteIcon from "@mui/icons-material/Delete";
import RestoreIcon from "@mui/icons-material/Restore";
import { showNotice } from "@/services/noticeService";
export type BackupFile = IWebDavFile & { export type BackupFile = IWebDavFile & {
platform: string; platform: string;
backup_time: Dayjs; backup_time: dayjs.Dayjs;
allow_apply: boolean; allow_apply: boolean;
}; };
@@ -36,154 +31,12 @@ export const DEFAULT_ROWS_PER_PAGE = 5;
export interface BackupTableViewerProps { export interface BackupTableViewerProps {
datasource: BackupFile[]; datasource: BackupFile[];
page: number; page: number;
onPageChange: ( onPageChange: (event: React.MouseEvent<HTMLButtonElement> | null, page: number) => void;
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void;
total: number; total: number;
onRefresh: () => Promise<void>; onRefresh: () => Promise<void>;
} }
export const BackupTableViewer = memo( // Ваши кастомные иконки остаются без изменений
({
datasource,
page,
onPageChange,
total,
onRefresh,
}: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename).then(() => {
showNotice("success", t("Restore Success, App will restart in 1s"));
});
await restartApp();
});
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Filename")}</TableCell>
<TableCell>{t("Backup Time")}</TableCell>
<TableCell align="right">{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{datasource.length > 0 ? (
datasource?.map((file, index) => (
<TableRow key={index}>
<TableCell component="th" scope="row">
{file.platform === "windows" ? (
<WindowsIcon className="h-full w-full" />
) : file.platform === "linux" ? (
<LinuxIcon className="h-full w-full" />
) : (
<MacIcon className="h-full w-full" />
)}
{file.filename}
</TableCell>
<TableCell align="center">
{file.backup_time.fromNow()}
</TableCell>
<TableCell align="right">
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
}}
>
<IconButton
color="secondary"
aria-label={t("Delete")}
size="small"
title={t("Delete Backup")}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to delete this backup file?"),
);
if (confirmed) {
await handleDelete(file.filename);
}
}}
>
<DeleteIcon />
</IconButton>
<Divider
orientation="vertical"
flexItem
sx={{ mx: 1, height: 24 }}
/>
<IconButton
color="primary"
aria-label={t("Restore")}
size="small"
title={t("Restore Backup")}
disabled={!file.allow_apply}
onClick={async (e: React.MouseEvent) => {
e.preventDefault();
const confirmed = await window.confirm(
t("Confirm to restore this backup file?"),
);
if (confirmed) {
await handleRestore(file.filename);
}
}}
>
<RestoreIcon />
</IconButton>
</Box>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} align="center">
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: 150,
}}
>
<Typography
variant="body1"
color="textSecondary"
align="center"
>
{t("No Backups")}
</Typography>
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
<TablePagination
rowsPerPageOptions={[]}
component="div"
count={total}
rowsPerPage={DEFAULT_ROWS_PER_PAGE}
page={page}
onPageChange={onPageChange}
labelRowsPerPage={t("Rows per page")}
/>
</TableContainer>
);
},
);
function LinuxIcon(props: SVGProps<SVGSVGElement>) { function LinuxIcon(props: SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg
@@ -264,3 +117,120 @@ function MacIcon(props: SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export const BackupTableViewer = memo(
({ datasource, page, onPageChange, total, onRefresh }: BackupTableViewerProps) => {
const { t } = useTranslation();
const handleDelete = useLockFn(async (filename: string) => {
await deleteWebdavBackup(filename);
await onRefresh();
});
const handleRestore = useLockFn(async (filename: string) => {
await restoreWebDavBackup(filename);
showNotice("success", t("Restore Success, App will restart in 1s"));
await restartApp();
});
return (
// Используем простой div в качестве контейнера
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("Filename")}</TableHead>
<TableHead className="text-center">{t("Backup Time")}</TableHead>
<TableHead className="text-right">{t("Actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{datasource.length > 0 ? (
datasource.map((file, index) => (
<TableRow key={index}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{file.platform === "windows" ? ( <WindowsIcon className="h-5 w-5" />
) : file.platform === "linux" ? ( <LinuxIcon className="h-5 w-5" />
) : ( <MacIcon className="h-5 w-5" /> )}
<span>{file.filename}</span>
</div>
</TableCell>
<TableCell className="text-center">{file.backup_time.fromNow()}</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={async () => {
const confirmed = window.confirm(t("Confirm to delete this backup file?"));
if (confirmed) await handleDelete(file.filename);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Delete Backup")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={!file.allow_apply}
onClick={async () => {
const confirmed = window.confirm(t("Confirm to restore this backup file?"));
if (confirmed) await handleRestore(file.filename);
}}
>
<History className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Restore Backup")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center">
{t("No Backups")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Новая кастомная пагинация */}
<div className="flex items-center justify-end space-x-2 p-2 border-t border-border">
<div className="flex-1 text-sm text-muted-foreground">
{t("Total")} {total}
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page - 1)}
disabled={page === 0}
>
{t("Previous")}
</Button>
<Button
variant="outline"
size="sm"
onClick={(e) => onPageChange(e, page + 1)}
disabled={(page + 1) * DEFAULT_ROWS_PER_PAGE >= total}
>
{t("Next")}
</Button>
</div>
</div>
);
}
);

View File

@@ -1,57 +1,68 @@
import { import { forwardRef, useImperativeHandle, useState, useCallback, useEffect } from "react";
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base"; import dayjs, { Dayjs } from "dayjs";
import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from "@/components/base";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat"; import customParseFormat from "dayjs/plugin/customParseFormat";
import { import { useLockFn } from "ahooks";
BackupTableViewer,
BackupFile, // Новые импорты
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { BackupConfigViewer } from "./backup-config-viewer";
import { Box, Paper, Divider } from "@mui/material";
import { listWebDavBackup } from "@/services/cmds"; import { listWebDavBackup } from "@/services/cmds";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { BaseLoadingOverlay } from "@/components/base"; // Наш рефакторенный компонент
import { BackupTableViewer, BackupFile, DEFAULT_ROWS_PER_PAGE } from "./backup-table-viewer"; // Наш рефакторенный компонент
import { BackupConfigViewer } from "./backup-config-viewer"; // Наш рефакторенный компонент
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss"; const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/; const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
export interface DialogRef {
open: () => void;
close: () => void;
}
export const BackupViewer = forwardRef<DialogRef>((props, ref) => { export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]); const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [dataSource, setDataSource] = useState<BackupFile[]>([]); const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const OS = getSystem();
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => setOpen(true),
setOpen(true);
},
close: () => setOpen(false), close: () => setOpen(false),
})); }));
// Handle page change
const handleChangePage = useCallback( const handleChangePage = useCallback(
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => { (_: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
setPage(page); setPage(newPage);
}, },
[], [],
); );
const fetchAndSetBackupFiles = async () => { const getAllBackupFiles = async (): Promise<BackupFile[]> => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN);
if (fileBackupTimeStr === null) return null;
return {
...file,
platform,
backup_time: dayjs(fileBackupTimeStr[0], DATE_FORMAT),
allow_apply: true,
};
})
.filter((item): item is BackupFile => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
const fetchAndSetBackupFiles = useLockFn(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const files = await getAllBackupFiles(); const files = await getAllBackupFiles();
@@ -61,35 +72,10 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
setBackupFiles([]); setBackupFiles([]);
setTotal(0); setTotal(0);
console.error(error); console.error(error);
// Notice.error(t("Failed to fetch backup files"));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; });
const getAllBackupFiles = async () => {
const files = await listWebDavBackup();
return files
.map((file) => {
const platform = file.filename.split("-")[0];
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
if (fileBackupTimeStr === null) {
return null;
}
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
const allowApply = true;
return {
...file,
platform,
backup_time: backupTime,
allow_apply: allowApply,
} as BackupFile;
})
.filter((item) => item !== null)
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
};
useEffect(() => { useEffect(() => {
setDataSource( setDataSource(
@@ -101,35 +87,26 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
}, [page, backupFiles]); }, [page, backupFiles]);
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="max-w-4xl">
title={t("Backup Setting")} <DialogHeader>
// contentSx={{ width: 600, maxHeight: 800 }} <DialogTitle>{t("Backup Setting")}</DialogTitle>
okBtn={t("")} </DialogHeader>
cancelBtn={t("Close")}
onClose={() => setOpen(false)} {/* Основной контейнер с relative для оверлея загрузки */}
onCancel={() => setOpen(false)} <div className="relative space-y-4">
disableOk <BaseLoadingOverlay isLoading={isLoading} />
>
<Box>
<BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}>
<BackupConfigViewer <BackupConfigViewer
setLoading={setIsLoading} setLoading={setIsLoading}
onBackupSuccess={async () => { onBackupSuccess={fetchAndSetBackupFiles}
fetchAndSetBackupFiles(); onSaveSuccess={fetchAndSetBackupFiles}
}} onRefresh={fetchAndSetBackupFiles}
onSaveSuccess={async () => { onInit={fetchAndSetBackupFiles}
fetchAndSetBackupFiles();
}}
onRefresh={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}}
/> />
<Divider sx={{ marginY: 2 }} />
<Separator />
<BackupTableViewer <BackupTableViewer
datasource={dataSource} datasource={dataSource}
page={page} page={page}
@@ -137,8 +114,14 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
total={total} total={total}
onRefresh={fetchAndSetBackupFiles} onRefresh={fetchAndSetBackupFiles}
/> />
</Paper> </div>
</Box>
</BaseDialog> <DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

View File

@@ -1,26 +1,31 @@
import { mutate } from "swr";
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { BaseDialog, DialogRef } from "@/components/base";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { LoadingButton } from "@mui/lab"; import { mutate } from "swr";
// Новые импорты
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import { import {
SwitchAccessShortcutRounded, Dialog,
RestartAltRounded, DialogContent,
} from "@mui/icons-material"; DialogHeader,
import { DialogTitle,
Box, DialogFooter,
Chip, DialogClose,
CircularProgress, } from "@/components/ui/dialog";
List, import { Badge } from "@/components/ui/badge";
ListItemButton, import { Loader2, Replace, RotateCw } from "lucide-react";
ListItemText, import { cn } from "@root/lib/utils";
} from "@mui/material";
// Логика и сервисы
import { useVerge } from "@/hooks/use-verge";
import { changeClashCore, restartCore } from "@/services/cmds"; import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api"; import { closeAllConnections, upgradeCore } from "@/services/api";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// Константы и интерфейсы
const VALID_CORE = [ const VALID_CORE = [
{ name: "Mihomo", core: "verge-mihomo", chip: "Release Version" }, { name: "Mihomo", core: "verge-mihomo", chip: "Release Version" },
{ name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" }, { name: "Mihomo Alpha", core: "verge-mihomo-alpha", chip: "Alpha Version" },
@@ -28,7 +33,6 @@ const VALID_CORE = [
export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => { export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, mutateVerge } = useVerge(); const { verge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -45,18 +49,15 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const onCoreChange = useLockFn(async (core: string) => { const onCoreChange = useLockFn(async (core: string) => {
if (core === clash_core) return; if (core === clash_core) return;
try { try {
setChangingCore(core); setChangingCore(core);
closeAllConnections(); closeAllConnections();
const errorMsg = await changeClashCore(core); const errorMsg = await changeClashCore(core);
if (errorMsg) { if (errorMsg) {
showNotice("error", errorMsg); showNotice("error", errorMsg);
setChangingCore(null); setChangingCore(null);
return; return;
} }
mutateVerge(); mutateVerge();
setTimeout(() => { setTimeout(() => {
mutate("getClashConfig"); mutate("getClashConfig");
@@ -74,10 +75,10 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
setRestarting(true); setRestarting(true);
await restartCore(); await restartCore();
showNotice("success", t(`Clash Core Restarted`)); showNotice("success", t(`Clash Core Restarted`));
setRestarting(false);
} catch (err: any) { } catch (err: any) {
setRestarting(false);
showNotice("error", err.message || err.toString()); showNotice("error", err.message || err.toString());
} finally {
setRestarting(false);
} }
}); });
@@ -85,81 +86,79 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
try { try {
setUpgrading(true); setUpgrading(true);
await upgradeCore(); await upgradeCore();
setUpgrading(false);
showNotice("success", t(`Core Version Updated`)); showNotice("success", t(`Core Version Updated`));
} catch (err: any) { } catch (err: any) {
setUpgrading(false);
const errMsg = err.response?.data?.message || err.toString(); const errMsg = err.response?.data?.message || err.toString();
const showMsg = errMsg.includes("already using latest version") const showMsg = errMsg.includes("already using latest version")
? "Already Using Latest Core Version" ? "Already Using Latest Core Version"
: errMsg; : errMsg;
showNotice("error", t(showMsg)); showNotice("error", t(showMsg));
} finally {
setUpgrading(false);
} }
}); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={ {/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
<Box display="flex" justifyContent="space-between"> {/* Добавляем отступ справа (pr-12), чтобы освободить место для крестика */}
{t("Clash Core")} <DialogHeader className="pr-12">
<Box> <div className="flex justify-between items-center">
<LoadingButton <DialogTitle>{t("Clash Core")}</DialogTitle>
variant="contained" <div className="flex items-center gap-2">
size="small" <Button size="sm" disabled={restarting || changingCore !== null} onClick={onUpgrade}>
startIcon={<SwitchAccessShortcutRounded />} {upgrading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Replace className="mr-2 h-4 w-4" />}
loadingPosition="start" {t("Upgrade")}
loading={upgrading} </Button>
disabled={restarting || changingCore !== null} <Button size="sm" disabled={upgrading || changingCore !== null} onClick={onRestart}>
sx={{ marginRight: "8px" }} {restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCw className="mr-2 h-4 w-4" />}
onClick={onUpgrade} {t("Restart")}
> </Button>
{t("Upgrade")} </div>
</LoadingButton> </div>
<LoadingButton </DialogHeader>
variant="contained" {/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
size="small"
startIcon={<RestartAltRounded />} <div className="space-y-2 py-4">
loadingPosition="start" {VALID_CORE.map((each) => {
loading={restarting} const isSelected = each.core === clash_core;
disabled={upgrading} const isChanging = changingCore === each.core;
onClick={onRestart} const isDisabled = changingCore !== null || restarting || upgrading;
>
{t("Restart")} return (
</LoadingButton> <div
</Box> key={each.core}
</Box> data-selected={isSelected}
} onClick={() => !isDisabled && onCoreChange(each.core)}
contentSx={{ className={cn(
pb: 0, "flex items-center justify-between p-3 rounded-md transition-colors",
width: 400, isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent",
height: 180, isSelected && "bg-accent"
overflowY: "auto", )}
userSelect: "text", >
marginTop: "-8px", <div>
}} <p className="font-semibold text-sm">{each.name}</p>
disableOk <p className="text-xs text-muted-foreground">{`/${each.core}`}</p>
cancelBtn={t("Close")} </div>
onClose={() => setOpen(false)} <div className="w-28 text-right flex justify-end">
onCancel={() => setOpen(false)} {isChanging ? (
> <Loader2 className="h-5 w-5 animate-spin" />
<List component="nav"> ) : (
{VALID_CORE.map((each) => ( <Badge variant={isSelected ? "default" : "secondary"}>{t(each.chip)}</Badge>
<ListItemButton )}
key={each.core} </div>
selected={each.core === clash_core} </div>
onClick={() => onCoreChange(each.core)} );
disabled={changingCore !== null || restarting || upgrading} })}
> </div>
<ListItemText primary={each.name} secondary={`/${each.core}`} />
{changingCore === each.core ? ( <DialogFooter>
<CircularProgress size={20} sx={{ mr: 1 }} /> <DialogClose asChild>
) : ( <Button type="button" variant="outline">{t("Close")}</Button>
<Chip label={t(`${each.chip}`)} size="small" /> </DialogClose>
)} </DialogFooter>
</ListItemButton> </DialogContent>
))} </Dialog>
</List>
</BaseDialog>
); );
}); });

View File

@@ -1,69 +1,120 @@
import { BaseDialog, Switch } from "@/components/base"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn, useRequest } from "ahooks";
import { useClashInfo } from "@/hooks/use-clash"; import { useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import { Shuffle } from "@mui/icons-material";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { import {
CircularProgress, Dialog,
IconButton, DialogContent,
List, DialogHeader,
ListItem, DialogTitle,
ListItemText, DialogFooter,
Stack, DialogClose,
TextField, } from "@/components/ui/dialog";
} from "@mui/material"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useLockFn, useRequest } from "ahooks"; import { Shuffle, Loader2 } from "lucide-react";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
const OS = getSystem(); const OS = getSystem();
interface ClashPortViewerProps {}
interface ClashPortViewerRef { interface ClashPortViewerRef {
open: () => void; open: () => void;
close: () => void; close: () => void;
} }
const generateRandomPort = () => const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
export const ClashPortViewer = forwardRef< // Компонент для одной строки настроек порта
ClashPortViewerRef, const PortSettingRow = ({
ClashPortViewerProps label,
>((props, ref) => { port,
setPort,
isEnabled,
setIsEnabled,
isFixed = false,
}: {
label: string;
port: number;
setPort: (port: number) => void;
isEnabled: boolean;
setIsEnabled?: (enabled: boolean) => void;
isFixed?: boolean;
}) => {
const { t } = useTranslation();
const handleNumericChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, ""); // Удаляем все нечисловые символы
if (value === "") {
setPort(0);
return;
}
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 0 && num <= 65535) {
setPort(num);
}
};
return (
<div className="flex items-center justify-between py-2">
<p className="text-sm font-medium">{label}</p>
<div className="flex items-center gap-2">
<Input
type="number"
className="w-24 h-8 text-center"
value={port || ""}
onChange={handleNumericChange}
disabled={!isEnabled}
/>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setPort(generateRandomPort())}
disabled={!isEnabled}
>
<Shuffle className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Random Port")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<Switch
checked={isEnabled}
onCheckedChange={isFixed ? undefined : setIsEnabled}
disabled={isFixed}
/>
</div>
</div>
);
};
export const ClashPortViewer = forwardRef<ClashPortViewerRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clashInfo, patchInfo } = useClashInfo(); const { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Mixed Port const [mixedPort, setMixedPort] = useState(0);
const [mixedPort, setMixedPort] = useState( const [socksPort, setSocksPort] = useState(0);
verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897, const [socksEnabled, setSocksEnabled] = useState(false);
); const [httpPort, setHttpPort] = useState(0);
const [httpEnabled, setHttpEnabled] = useState(false);
const [redirPort, setRedirPort] = useState(0);
const [redirEnabled, setRedirEnabled] = useState(false);
const [tproxyPort, setTproxyPort] = useState(0);
const [tproxyEnabled, setTproxyEnabled] = useState(false);
// 其他端口状态
const [socksPort, setSocksPort] = useState(verge?.verge_socks_port ?? 7898);
const [socksEnabled, setSocksEnabled] = useState(
verge?.verge_socks_enabled ?? false,
);
const [httpPort, setHttpPort] = useState(verge?.verge_port ?? 7899);
const [httpEnabled, setHttpEnabled] = useState(
verge?.verge_http_enabled ?? false,
);
const [redirPort, setRedirPort] = useState(verge?.verge_redir_port ?? 7895);
const [redirEnabled, setRedirEnabled] = useState(
verge?.verge_redir_enabled ?? false,
);
const [tproxyPort, setTproxyPort] = useState(
verge?.verge_tproxy_port ?? 7896,
);
const [tproxyEnabled, setTproxyEnabled] = useState(
verge?.verge_tproxy_enabled ?? false,
);
// 添加保存请求防止GUI卡死
const { loading, run: saveSettings } = useRequest( const { loading, run: saveSettings } = useRequest(
async (params: { clashConfig: any; vergeConfig: any }) => { async (params: { clashConfig: any; vergeConfig: any }) => {
const { clashConfig, vergeConfig } = params; const { clashConfig, vergeConfig } = params;
@@ -73,24 +124,24 @@ export const ClashPortViewer = forwardRef<
manual: true, manual: true,
onSuccess: () => { onSuccess: () => {
setOpen(false); setOpen(false);
showNotice("success", t("Port settings saved")); // 调用提示函数 showNotice("success", t("Port settings saved"));
}, },
onError: () => { onError: () => {
showNotice("error", t("Failed to save settings")); // 调用提示函数 showNotice("error", t("Failed to save settings"));
}, },
}, },
); );
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897); setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7890);
setSocksPort(verge?.verge_socks_port ?? 7898); setSocksPort(verge?.verge_socks_port ?? 7891);
setSocksEnabled(verge?.verge_socks_enabled ?? false); setSocksEnabled(verge?.verge_socks_enabled ?? false);
setHttpPort(verge?.verge_port ?? 7899); setHttpPort(verge?.verge_port ?? 7892);
setHttpEnabled(verge?.verge_http_enabled ?? false); setHttpEnabled(verge?.verge_http_enabled ?? false);
setRedirPort(verge?.verge_redir_port ?? 7895); setRedirPort(verge?.verge_redir_port ?? 7893);
setRedirEnabled(verge?.verge_redir_enabled ?? false); setRedirEnabled(verge?.verge_redir_enabled ?? false);
setTproxyPort(verge?.verge_tproxy_port ?? 7896); setTproxyPort(verge?.verge_tproxy_port ?? 7894);
setTproxyEnabled(verge?.verge_tproxy_enabled ?? false); setTproxyEnabled(verge?.verge_tproxy_enabled ?? false);
setOpen(true); setOpen(true);
}, },
@@ -98,40 +149,31 @@ export const ClashPortViewer = forwardRef<
})); }));
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
// 端口冲突检测
const portList = [ const portList = [
mixedPort, mixedPort,
socksEnabled ? socksPort : -1, socksEnabled ? socksPort : -1,
httpEnabled ? httpPort : -1, httpEnabled ? httpPort : -1,
redirEnabled ? redirPort : -1, redirEnabled ? redirPort : -1,
tproxyEnabled ? tproxyPort : -1, tproxyEnabled ? tproxyPort : -1,
].filter((p) => p !== -1); ].filter((p) => p > 0);
if (new Set(portList).size !== portList.length) { if (new Set(portList).size !== portList.length) {
showNotice("error", t("Port conflict detected"));
return; return;
} }
// 验证端口范围 const allPortsValid = portList.every((port) => port >= 1 && port <= 65535);
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const allPortsValid = [
mixedPort,
socksEnabled ? socksPort : 0,
httpEnabled ? httpPort : 0,
redirEnabled ? redirPort : 0,
tproxyEnabled ? tproxyPort : 0,
].every((port) => port === 0 || isValidPort(port));
if (!allPortsValid) { if (!allPortsValid) {
showNotice("error", t("Port out of range (1-65535)"));
return; return;
} }
// 准备配置数据
const clashConfig = { const clashConfig = {
"mixed-port": mixedPort, "mixed-port": mixedPort,
"socks-port": socksPort, "socks-port": socksEnabled ? socksPort : 0,
port: httpPort, "port": httpEnabled ? httpPort : 0,
"redir-port": redirPort, "redir-port": redirEnabled ? redirPort : 0,
"tproxy-port": tproxyPort, "tproxy-port": tproxyEnabled ? tproxyPort : 0,
}; };
const vergeConfig = { const vergeConfig = {
@@ -146,221 +188,36 @@ export const ClashPortViewer = forwardRef<
verge_tproxy_enabled: tproxyEnabled, verge_tproxy_enabled: tproxyEnabled,
}; };
// 提交保存请求
await saveSettings({ clashConfig, vergeConfig }); await saveSettings({ clashConfig, vergeConfig });
}); });
// 优化的数字输入处理
const handleNumericChange =
(setter: (value: number) => void) =>
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D+/, "");
if (value === "") {
setter(0);
return;
}
const num = parseInt(value, 10);
if (!isNaN(num) && num >= 0 && num <= 65535) {
setter(num);
}
};
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={t("Port Configuration")} <DialogHeader>
contentSx={{ <DialogTitle>{t("Port Configuration")}</DialogTitle>
width: 400, </DialogHeader>
}}
okBtn={
loading ? (
<Stack direction="row" alignItems="center" spacing={1}>
<CircularProgress size={20} />
{t("Saving...")}
</Stack>
) : (
t("Save")
)
}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ width: "100%" }}>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Mixed Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={mixedPort}
onChange={(e) =>
setMixedPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setMixedPort(generateRandomPort())}
title={t("Random Port")}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={true}
disabled={true}
sx={{ ml: 0.5, opacity: 0.7 }}
/>
</div>
</ListItem>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}> <div className="py-4 space-y-1">
<ListItemText <PortSettingRow label={t("Mixed Port")} port={mixedPort} setPort={setMixedPort} isEnabled={true} isFixed={true} />
primary={t("Socks Port")} <PortSettingRow label={t("Socks Port")} port={socksPort} setPort={setSocksPort} isEnabled={socksEnabled} setIsEnabled={setSocksEnabled} />
primaryTypographyProps={{ fontSize: 12 }} <PortSettingRow label={t("Http Port")} port={httpPort} setPort={setHttpPort} isEnabled={httpEnabled} setIsEnabled={setHttpEnabled} />
/> {OS !== "windows" && (
<div style={{ display: "flex", alignItems: "center" }}> <PortSettingRow label={t("Redir Port")} port={redirPort} setPort={setRedirPort} isEnabled={redirEnabled} setIsEnabled={setRedirEnabled} />
<TextField )}
size="small" {OS === "linux" && (
sx={{ width: 80, mr: 0.5, fontSize: 12 }} <PortSettingRow label={t("Tproxy Port")} port={tproxyPort} setPort={setTproxyPort} isEnabled={tproxyEnabled} setIsEnabled={setTproxyEnabled} />
value={socksPort} )}
onChange={(e) => </div>
setSocksPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!socksEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setSocksPort(generateRandomPort())}
title={t("Random Port")}
disabled={!socksEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={socksEnabled}
onChange={(_, c) => setSocksEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}> <DialogFooter>
<ListItemText <DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
primary={t("Http Port")} <Button type="button" onClick={onSave} disabled={loading}>
primaryTypographyProps={{ fontSize: 12 }} {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
/> {t("Save")}
<div style={{ display: "flex", alignItems: "center" }}> </Button>
<TextField </DialogFooter>
size="small" </DialogContent>
sx={{ width: 80, mr: 0.5, fontSize: 12 }} </Dialog>
value={httpPort}
onChange={(e) =>
setHttpPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!httpEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setHttpPort(generateRandomPort())}
title={t("Random Port")}
disabled={!httpEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={httpEnabled}
onChange={(_, c) => setHttpEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
{OS !== "windows" && (
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Redir Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={redirPort}
onChange={(e) =>
setRedirPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!redirEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setRedirPort(generateRandomPort())}
title={t("Random Port")}
disabled={!redirEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={redirEnabled}
onChange={(_, c) => setRedirEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
{OS === "linux" && (
<ListItem sx={{ padding: "4px 0", minHeight: 36 }}>
<ListItemText
primary={t("Tproxy Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={tproxyPort}
onChange={(e) =>
setTproxyPort(+e.target.value?.replace(/\D+/, "").slice(0, 5))
}
disabled={!tproxyEnabled}
inputProps={{ style: { fontSize: 12 } }}
/>
<IconButton
size="small"
onClick={() => setTproxyPort(generateRandomPort())}
title={t("Random Port")}
disabled={!tproxyEnabled}
sx={{ mr: 0.5 }}
>
<Shuffle fontSize="small" />
</IconButton>
<Switch
size="small"
checked={tproxyEnabled}
onChange={(_, c) => setTproxyEnabled(c)}
sx={{ ml: 0.5 }}
/>
</div>
</ListItem>
)}
</List>
</BaseDialog>
); );
}); });

View File

@@ -1,15 +1,18 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Box, Chip } from "@mui/material";
import { getRuntimeYaml } from "@/services/cmds"; import { getRuntimeYaml } from "@/services/cmds";
import { DialogRef } from "@/components/base"; import { DialogRef } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer"; // Наш обновленный компонент
// Новые импорты
import { Badge } from "@/components/ui/badge";
export const ConfigViewer = forwardRef<DialogRef>((_, ref) => { export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [runtimeConfig, setRuntimeConfig] = useState(""); const [runtimeConfig, setRuntimeConfig] = useState("");
// useImperativeHandle остается без изменений
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
getRuntimeYaml().then((data) => { getRuntimeYaml().then((data) => {
@@ -21,14 +24,18 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
})); }));
if (!open) return null; if (!open) return null;
return ( return (
<EditorViewer <EditorViewer
open={true} open={true}
title={ title={
<Box display="flex" alignItems="center" gap={2}> // --- НАЧАЛО ИЗМЕНЕНИЙ ---
{t("Runtime Config")} // Заменяем Box на div и Chip на Badge
<Chip label={t("ReadOnly")} size="small" /> <div className="flex items-center gap-2">
</Box> <span>{t("Runtime Config")}</span>
<Badge variant="secondary">{t("ReadOnly")}</Badge>
</div>
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
} }
initialData={Promise.resolve(runtimeConfig)} initialData={Promise.resolve(runtimeConfig)}
readOnly readOnly

View File

@@ -1,185 +1,144 @@
import { BaseDialog, DialogRef } from "@/components/base";
import { useClashInfo } from "@/hooks/use-clash";
import { showNotice } from "@/services/noticeService";
import { ContentCopy } from "@mui/icons-material";
import {
Alert,
Box,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
Snackbar,
TextField,
Tooltip,
} from "@mui/material";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { useClashInfo } from "@/hooks/use-clash";
import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Copy, Loader2 } from "lucide-react";
export const ControllerViewer = forwardRef<DialogRef>((props, ref) => { export const ControllerViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState<null | string>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const { clashInfo, patchInfo } = useClashInfo(); const { clashInfo, patchInfo } = useClashInfo();
const [controller, setController] = useState(clashInfo?.server || ""); const [controller, setController] = useState("");
const [secret, setSecret] = useState(clashInfo?.secret || ""); const [secret, setSecret] = useState("");
// 对话框打开时初始化配置
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: async () => { open: async () => {
setOpen(true);
setController(clashInfo?.server || ""); setController(clashInfo?.server || "");
setSecret(clashInfo?.secret || ""); setSecret(clashInfo?.secret || "");
setOpen(true);
}, },
close: () => setOpen(false), close: () => setOpen(false),
})); }));
// 保存配置
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
if (!controller.trim()) { if (!controller.trim()) {
showNotice("error", t("Controller address cannot be empty")); showNotice("error", t("Controller address cannot be empty"));
return; return;
} }
// Секрет может быть пустым
if (!secret.trim()) { // if (!secret.trim()) {
showNotice("error", t("Secret cannot be empty")); // showNotice("error", t("Secret cannot be empty"));
return; // return;
} // }
try { try {
setIsSaving(true); setIsSaving(true);
await patchInfo({ "external-controller": controller, secret }); await patchInfo({ "external-controller": controller, secret });
showNotice("success", t("Configuration saved successfully")); showNotice("success", t("Configuration saved successfully"));
setOpen(false); setOpen(false);
} catch (err: any) { } catch (err: any) {
showNotice( showNotice("error", err.message || t("Failed to save configuration"), 4000);
"error",
err.message || t("Failed to save configuration"),
4000,
);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}); });
// 复制到剪贴板 const handleCopyToClipboard = useLockFn(async (text: string, type: string) => {
const handleCopyToClipboard = useLockFn( try {
async (text: string, type: string) => { await navigator.clipboard.writeText(text);
try { // --- ИЗМЕНЕНИЕ: Используем showNotice вместо Snackbar ---
await navigator.clipboard.writeText(text); const message = type === "controller"
setCopySuccess(type); ? t("Controller address copied to clipboard")
setTimeout(() => setCopySuccess(null)); : t("Secret copied to clipboard");
} catch (err) { showNotice("success", message);
showNotice("error", t("Failed to copy")); } catch (err) {
} showNotice("error", t("Failed to copy"));
}, }
); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={t("External Controller")} <DialogHeader>
contentSx={{ width: 400 }} <DialogTitle>{t("External Controller")}</DialogTitle>
okBtn={ </DialogHeader>
isSaving ? (
<Box display="flex" alignItems="center" gap={1}>
<CircularProgress size={16} color="inherit" />
{t("Saving...")}
</Box>
) : (
t("Save")
)
}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem
sx={{
padding: "5px 2px",
display: "flex",
justifyContent: "space-between",
}}
>
<ListItemText primary={t("External Controller")} />
<Box display="flex" alignItems="center" gap={1}>
<TextField
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
}}
value={controller}
placeholder="Required"
onChange={(e) => setController(e.target.value)}
disabled={isSaving}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(controller, "controller")}
color="primary"
disabled={isSaving}
>
<ContentCopy fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</ListItem>
<ListItem <div className="space-y-4 py-4">
sx={{ <div className="grid gap-2">
padding: "5px 2px", <Label htmlFor="controller-address">{t("External Controller")}</Label>
display: "flex", <div className="flex items-center gap-2">
justifyContent: "space-between", <Input
}} id="controller-address"
> value={controller}
<ListItemText primary={t("Core Secret")} /> placeholder="127.0.0.1:9090"
<Box display="flex" alignItems="center" gap={1}> onChange={(e) => setController(e.target.value)}
<TextField
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
}}
value={secret}
placeholder={t("Recommended")}
onChange={(e) => setSecret(e.target.value)}
disabled={isSaving}
/>
<Tooltip title={t("Copy to clipboard")}>
<IconButton
size="small"
onClick={() => handleCopyToClipboard(secret, "secret")}
color="primary"
disabled={isSaving} disabled={isSaving}
> />
<ContentCopy fontSize="small" /> <TooltipProvider>
</IconButton> <Tooltip>
</Tooltip> <TooltipTrigger asChild>
</Box> <Button variant="ghost" size="icon" onClick={() => handleCopyToClipboard(controller, "controller")} disabled={isSaving}>
</ListItem> <Copy className="h-4 w-4" />
</List> </Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<Snackbar <div className="grid gap-2">
open={copySuccess !== null} <Label htmlFor="core-secret">{t("Core Secret")}</Label>
autoHideDuration={2000} <div className="flex items-center gap-2">
anchorOrigin={{ vertical: "bottom", horizontal: "right" }} <Input
> id="core-secret"
<Alert severity="success"> value={secret}
{copySuccess === "controller" placeholder={t("Recommended")}
? t("Controller address copied to clipboard") onChange={(e) => setSecret(e.target.value)}
: t("Secret copied to clipboard")} disabled={isSaving}
</Alert> />
</Snackbar> <TooltipProvider>
</BaseDialog> <Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleCopyToClipboard(secret, "secret")} disabled={isSaving}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button type="button" onClick={onSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,14 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { alpha, Box, IconButton, styled } from "@mui/material";
import { DeleteRounded } from "@mui/icons-material";
import { parseHotkey } from "@/utils/parse-hotkey";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { parseHotkey } from "@/utils/parse-hotkey";
import { cn } from "@root/lib/utils";
const KeyWrapper = styled("div")(({ theme }) => ({ // Новые импорты
position: "relative", import { Input } from "@/components/ui/input";
width: 165, import { Badge } from "@/components/ui/badge";
minHeight: 36, import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
"> input": {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
opacity: 0,
},
"> input:focus + .list": {
borderColor: alpha(theme.palette.primary.main, 0.75),
},
".list": {
display: "flex",
alignItems: "center",
flexWrap: "wrap",
width: "100%",
height: "100%",
minHeight: 36,
boxSizing: "border-box",
padding: "3px 4px",
border: "1px solid",
borderRadius: 4,
borderColor: alpha(theme.palette.text.secondary, 0.15),
"&:last-child": {
marginRight: 0,
},
},
".item": {
color: theme.palette.text.primary,
border: "1px solid",
borderColor: alpha(theme.palette.text.secondary, 0.2),
borderRadius: "2px",
padding: "1px 5px",
margin: "2px 0",
},
".delimiter": {
lineHeight: "25px",
padding: "0 2px",
},
}));
interface Props { interface Props {
value: string[]; value: string[];
@@ -63,55 +22,66 @@ export const HotkeyInput = (props: Props) => {
const changeRef = useRef<string[]>([]); const changeRef = useRef<string[]>([]);
const [keys, setKeys] = useState(value); const [keys, setKeys] = useState(value);
const handleKeyUp = () => {
const ret = changeRef.current.slice();
if (ret.length) {
onChange(ret);
changeRef.current = [];
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
e.stopPropagation();
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Передаем e.key (строку), а не e.nativeEvent (объект)
const key = parseHotkey(e.key);
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
};
const handleClear = () => {
onChange([]);
setKeys([]);
changeRef.current = [];
};
return ( return (
<Box sx={{ display: "flex", alignItems: "center" }}> <div className="flex items-center gap-2">
<KeyWrapper> <div className="relative rounded-md ring-offset-background focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<input <Input
onKeyUp={() => { readOnly
const ret = changeRef.current.slice(); onKeyUp={handleKeyUp}
if (ret.length) { onKeyDown={handleKeyDown}
onChange(ret); className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0"
changeRef.current = [];
}
}}
onKeyDown={(e) => {
const evt = e.nativeEvent;
e.preventDefault();
e.stopPropagation();
const key = parseHotkey(evt.key);
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
}}
/> />
<div className="flex min-h-9 w-48 flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-2 text-sm">
<div className="list"> {keys && keys.length > 0 ? (
{keys.map((key, index) => ( keys.map((key) => (
<Box display="flex"> <Badge key={key} variant="secondary">
<span className="delimiter" hidden={index === 0}>
+
</span>
<div key={key} className="item">
{key} {key}
</div> </Badge>
</Box> ))
))} ) : (
<span className="text-muted-foreground">{t("Press any key")}</span>
)}
</div> </div>
</KeyWrapper> </div>
<IconButton <Button
size="small" variant="ghost"
size="icon"
className="h-8 w-8"
title={t("Delete")} title={t("Delete")}
color="inherit" onClick={handleClear}
onClick={() => {
onChange([]);
setKeys([]);
}}
> >
<DeleteRounded fontSize="inherit" /> <X className="h-4 w-4" />
</IconButton> </Button>
</Box> </div>
); );
}; };

View File

@@ -1,18 +1,24 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { styled, Typography, Switch } from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef } from "@/components/base";
import { HotkeyInput } from "./hotkey-input";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
const ItemWrapper = styled("div")` // Новые импорты
display: flex; import { DialogRef } from "@/components/base";
align-items: center; import { HotkeyInput } from "./hotkey-input"; // Наш обновленный компонент
justify-content: space-between; import { Switch } from "@/components/ui/switch"; // Стандартный Switch
margin-bottom: 8px; import { Button } from "@/components/ui/button";
`; import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
const HOTKEY_FUNC = [ const HOTKEY_FUNC = [
"open_or_close_dashboard", "open_or_close_dashboard",
@@ -31,27 +37,18 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({}); const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const [enableGlobalHotkey, setEnableHotkey] = useState( const [enableGlobalHotkey, setEnableHotkey] = useState(true);
verge?.enable_global_hotkey ?? true,
);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setOpen(true); setOpen(true);
setEnableHotkey(verge?.enable_global_hotkey ?? true);
const map = {} as typeof hotkeyMap; const map = {} as typeof hotkeyMap;
verge?.hotkeys?.forEach((text) => { verge?.hotkeys?.forEach((text) => {
const [func, key] = text.split(",").map((e) => e.trim()); const [func, key] = text.split(",").map((e) => e.trim());
if (!func || !key) return; if (!func || !key) return;
map[func] = key.split("+").map((e) => e.trim()).map((k) => (k === "PLUS" ? "+" : k));
map[func] = key
.split("+")
.map((e) => e.trim())
.map((k) => (k === "PLUS" ? "+" : k));
}); });
setHotkeyMap(map); setHotkeyMap(map);
}, },
close: () => setOpen(false), close: () => setOpen(false),
@@ -61,13 +58,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const hotkeys = Object.entries(hotkeyMap) const hotkeys = Object.entries(hotkeyMap)
.map(([func, keys]) => { .map(([func, keys]) => {
if (!func || !keys?.length) return ""; if (!func || !keys?.length) return "";
const key = keys.map((k) => k.trim()).filter(Boolean).map((k) => (k === "+" ? "PLUS" : k)).join("+");
const key = keys
.map((k) => k.trim())
.filter(Boolean)
.map((k) => (k === "+" ? "PLUS" : k))
.join("+");
if (!key) return ""; if (!key) return "";
return `${func},${key}`; return `${func},${key}`;
}) })
@@ -79,40 +70,51 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
enable_global_hotkey: enableGlobalHotkey, enable_global_hotkey: enableGlobalHotkey,
}); });
setOpen(false); setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err.toString()); showNotice("error", err.toString());
} }
}); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={t("Hotkey Setting")} <DialogHeader>
contentSx={{ width: 450, maxHeight: 380 }} <DialogTitle>{t("Hotkey Setting")}</DialogTitle>
okBtn={t("Save")} </DialogHeader>
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<ItemWrapper style={{ marginBottom: 16 }}>
<Typography>{t("Enable Global Hotkey")}</Typography>
<Switch
edge="end"
checked={enableGlobalHotkey}
onChange={(e) => setEnableHotkey(e.target.checked)}
/>
</ItemWrapper>
{HOTKEY_FUNC.map((func) => ( <div className="space-y-4 py-4">
<ItemWrapper key={func}> <div className="flex items-center justify-between">
<Typography>{t(func)}</Typography> <Label htmlFor="enable-global-hotkey" className="font-medium">
<HotkeyInput {t("Enable Global Hotkey")}
value={hotkeyMap[func] ?? []} </Label>
onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))} <Switch
/> id="enable-global-hotkey"
</ItemWrapper> checked={enableGlobalHotkey}
))} onCheckedChange={setEnableHotkey}
</BaseDialog> />
</div>
<Separator />
{HOTKEY_FUNC.map((func) => (
<div key={func} className="flex items-center justify-between">
<Label className="text-muted-foreground">{t(func)}</Label>
<HotkeyInput
value={hotkeyMap[func] ?? []}
onChange={(v) => setHotkeyMap((m) => ({ ...m, [func]: v }))}
/>
</div>
))}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Cancel")}</Button>
</DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

View File

@@ -1,41 +1,40 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useLockFn } from "ahooks";
List,
Button,
Select,
MenuItem,
styled,
ListItem,
ListItemText,
Box,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { GuardState } from "./guard-state";
import { open as openDialog } from "@tauri-apps/plugin-dialog"; import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { copyIconFile, getAppDir } from "@/services/cmds";
import { join } from "@tauri-apps/api/path";
import { exists } from "@tauri-apps/plugin-fs"; import { exists } from "@tauri-apps/plugin-fs";
import getSystem from "@/utils/get-system"; import { join } from "@tauri-apps/api/path";
// Новые импорты
import { useVerge } from "@/hooks/use-verge";
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { GuardState } from "./guard-state";
import { copyIconFile, getAppDir } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import getSystem from "@/utils/get-system";
const OS = getSystem(); const OS = getSystem();
const getIcons = async (icon_dir: string, name: string) => { const getIcons = async (icon_dir: string, name: string) => {
const updateTime = localStorage.getItem(`icon_${name}_update_time`) || ""; const updateTime = localStorage.getItem(`icon_${name}_update_time`) || "";
const icon_png = await join(icon_dir, `${name}-${updateTime}.png`); const icon_png = await join(icon_dir, `${name}-${updateTime}.png`);
const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`); const icon_ico = await join(icon_dir, `${name}-${updateTime}.ico`);
return { icon_png, icon_ico };
return {
icon_png,
icon_ico,
};
}; };
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2"><p className="text-sm font-medium">{label}</p>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
</div>
);
export const LayoutViewer = forwardRef<DialogRef>((props, ref) => { export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge(); const { verge, patchVerge, mutateVerge } = useVerge();
@@ -45,42 +44,21 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const [sysproxyIcon, setSysproxyIcon] = useState(""); const [sysproxyIcon, setSysproxyIcon] = useState("");
const [tunIcon, setTunIcon] = useState(""); const [tunIcon, setTunIcon] = useState("");
useEffect(() => { const initIconPath = useCallback(async () => {
initIconPath(); const appDir = await getAppDir();
const icon_dir = await join(appDir, "icons");
const { icon_png: common_icon_png, icon_ico: common_icon_ico } = await getIcons(icon_dir, "common");
const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } = await getIcons(icon_dir, "sysproxy");
const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons(icon_dir, "tun");
setCommonIcon(await exists(common_icon_ico) ? common_icon_ico : common_icon_png);
setSysproxyIcon(await exists(sysproxy_icon_ico) ? sysproxy_icon_ico : sysproxy_icon_png);
setTunIcon(await exists(tun_icon_ico) ? tun_icon_ico : tun_icon_png);
}, []); }, []);
async function initIconPath() { useEffect(() => {
const appDir = await getAppDir(); if (open) initIconPath();
}, [open, initIconPath]);
const icon_dir = await join(appDir, "icons");
const { icon_png: common_icon_png, icon_ico: common_icon_ico } =
await getIcons(icon_dir, "common");
const { icon_png: sysproxy_icon_png, icon_ico: sysproxy_icon_ico } =
await getIcons(icon_dir, "sysproxy");
const { icon_png: tun_icon_png, icon_ico: tun_icon_ico } = await getIcons(
icon_dir,
"tun",
);
if (await exists(common_icon_ico)) {
setCommonIcon(common_icon_ico);
} else {
setCommonIcon(common_icon_png);
}
if (await exists(sysproxy_icon_ico)) {
setSysproxyIcon(sysproxy_icon_ico);
} else {
setSysproxyIcon(sysproxy_icon_png);
}
if (await exists(tun_icon_ico)) {
setTunIcon(tun_icon_ico);
} else {
setTunIcon(tun_icon_png);
}
}
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => setOpen(true), open: () => setOpen(true),
@@ -95,298 +73,129 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
mutateVerge({ ...verge, ...patch }, false); mutateVerge({ ...verge, ...patch }, false);
}; };
const handleIconChange = useLockFn(async (type: 'common' | 'sysproxy' | 'tun') => {
const key = `${type}_tray_icon` as keyof IVergeConfig;
if (verge?.[key]) {
onChangeData({ [key]: false });
await patchVerge({ [key]: false });
} else {
const selected = await openDialog({
directory: false, multiple: false,
filters: [{ name: "Tray Icon Image", extensions: ["png", "ico"] }],
});
if (selected) {
const path = Array.isArray(selected) ? selected[0] : selected;
await copyIconFile(path, type);
await initIconPath();
onChangeData({ [key]: true });
await patchVerge({ [key]: true });
}
}
});
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={t("Layout Setting")} <DialogHeader>
contentSx={{ width: 450 }} <DialogTitle>{t("Layout Setting")}</DialogTitle>
disableOk </DialogHeader>
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List>
<Item>
<ListItemText primary={t("Traffic Graph")} />
<GuardState
value={verge?.traffic_graph ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ traffic_graph: e })}
onGuard={(e) => patchVerge({ traffic_graph: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item> <div className="py-4 space-y-1">
<ListItemText primary={t("Memory Usage")} /> <SettingRow label={t("Traffic Graph")}>
<GuardState <GuardState value={verge?.traffic_graph ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ traffic_graph: e })} onGuard={(e) => patchVerge({ traffic_graph: e })}>
value={verge?.enable_memory_usage ?? true} <Switch />
valueProps="checked" </GuardState>
onCatch={onError} </SettingRow>
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_memory_usage: e })}
onGuard={(e) => patchVerge({ enable_memory_usage: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item> <SettingRow label={t("Memory Usage")}>
<ListItemText primary={t("Proxy Group Icon")} /> <GuardState value={verge?.enable_memory_usage ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_memory_usage: e })} onGuard={(e) => patchVerge({ enable_memory_usage: e })}>
<GuardState <Switch />
value={verge?.enable_group_icon ?? true} </GuardState>
valueProps="checked" </SettingRow>
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_group_icon: e })}
onGuard={(e) => patchVerge({ enable_group_icon: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item> <SettingRow label={t("Proxy Group Icon")}>
<ListItemText <GuardState value={verge?.enable_group_icon ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_group_icon: e })} onGuard={(e) => patchVerge({ enable_group_icon: e })}>
primary={ <Switch />
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}> </GuardState>
<span>{t("Hover Jump Navigator")}</span> </SettingRow>
<TooltipIcon
title={t("Hover Jump Navigator Info")}
sx={{ opacity: "0.7" }}
/>
</Box>
}
/>
<GuardState
value={verge?.enable_hover_jump_navigator ?? true}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_hover_jump_navigator: e })}
onGuard={(e) => patchVerge({ enable_hover_jump_navigator: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item> <SettingRow label={t("Hover Jump Navigator")} extra={<TooltipIcon tooltip={t("Hover Jump Navigator Info")} />}>
<ListItemText primary={t("Nav Icon")} /> <GuardState value={verge?.enable_hover_jump_navigator ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_hover_jump_navigator: e })} onGuard={(e) => patchVerge({ enable_hover_jump_navigator: e })}>
<GuardState <Switch />
value={verge?.menu_icon ?? "monochrome"} </GuardState>
onCatch={onError} </SettingRow>
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ menu_icon: e })}
onGuard={(e) => patchVerge({ menu_icon: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>
<MenuItem value="colorful">{t("Colorful")}</MenuItem>
<MenuItem value="disable">{t("Disable")}</MenuItem>
</Select>
</GuardState>
</Item>
{OS === "macos" && ( <SettingRow label={t("Nav Icon")}>
<Item> <GuardState value={verge?.menu_icon ?? "monochrome"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ menu_icon: e })} onGuard={(e) => patchVerge({ menu_icon: e })}>
<ListItemText primary={t("Tray Icon")} /> {/* --- НАЧАЛО ИЗМЕНЕНИЙ 1 --- */}
<GuardState <Select
value={verge?.tray_icon ?? "monochrome"} onValueChange={(value) => onChangeData({ menu_icon: value as any })}
onCatch={onError} value={verge?.menu_icon}
onFormat={(e: any) => e.target.value} >
onChange={(e) => onChangeData({ tray_icon: e })} {/* --- КОНЕЦ ИЗМЕНЕНИЙ 1 --- */}
onGuard={(e) => patchVerge({ tray_icon: e })} <SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
> <SelectContent>
<Select <SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
size="small" <SelectItem value="colorful">{t("Colorful")}</SelectItem>
sx={{ width: 140, "> div": { py: "7.5px" } }} <SelectItem value="disable">{t("Disable")}</SelectItem>
> </SelectContent>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem> </Select>
<MenuItem value="colorful">{t("Colorful")}</MenuItem> </GuardState>
</Select> </SettingRow>
</GuardState>
</Item>
)}
{/* {OS === "macos" && (
<Item>
<ListItemText primary={t("Enable Tray Speed")} />
<GuardState
value={verge?.enable_tray_speed ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tray_speed: e })}
onGuard={(e) => patchVerge({ enable_tray_speed: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
)} */}
{OS === "macos" && (
<Item>
<ListItemText primary={t("Enable Tray Icon")} />
<GuardState
value={
verge?.enable_tray_icon === false &&
verge?.enable_tray_speed === false
? true
: (verge?.enable_tray_icon ?? true)
}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_tray_icon: e })}
onGuard={(e) => patchVerge({ enable_tray_icon: e })}
>
<Switch edge="end" />
</GuardState>
</Item>
)}
<Item> {OS === "macos" && (
<ListItemText primary={t("Common Tray Icon")} /> <>
<GuardState <SettingRow label={t("Tray Icon")}>
value={verge?.common_tray_icon} <GuardState value={verge?.tray_icon ?? "monochrome"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ tray_icon: e })} onGuard={(e) => patchVerge({ tray_icon: e })}>
onCatch={onError} {/* --- НАЧАЛО ИЗМЕНЕНИЙ 2 --- */}
onChange={(e) => onChangeData({ common_tray_icon: e })} <Select
onGuard={(e) => patchVerge({ common_tray_icon: e })} onValueChange={(value) => onChangeData({ tray_icon: value as any })}
> value={verge?.tray_icon}
<Button >
variant="outlined" {/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
size="small" <SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
startIcon={ <SelectContent>
verge?.common_tray_icon && <SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
commonIcon && ( <SelectItem value="colorful">{t("Colorful")}</SelectItem>
<img height="20px" src={convertFileSrc(commonIcon)} /> </SelectContent>
) </Select>
} </GuardState>
onClick={async () => { </SettingRow>
if (verge?.common_tray_icon) {
onChangeData({ common_tray_icon: false });
patchVerge({ common_tray_icon: false });
} else {
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tray Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected) { <SettingRow label={t("Enable Tray Icon")}>
await copyIconFile(`${selected}`, "common"); <GuardState value={verge?.enable_tray_icon ?? true} valueProps="checked" onCatch={onError} onFormat={onSwitchFormat} onChange={(e) => onChangeData({ enable_tray_icon: e })} onGuard={(e) => patchVerge({ enable_tray_icon: e })}>
await initIconPath(); <Switch />
onChangeData({ common_tray_icon: true }); </GuardState>
patchVerge({ common_tray_icon: true }); </SettingRow>
console.log(); </>
} )}
}
}}
>
{verge?.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
<Item> <SettingRow label={t("Common Tray Icon")}>
<ListItemText primary={t("System Proxy Tray Icon")} /> <Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('common')}>
<GuardState {verge?.common_tray_icon && commonIcon && <img src={convertFileSrc(commonIcon)} className="h-5 mr-2" alt="common tray icon" />}
value={verge?.sysproxy_tray_icon} {verge?.common_tray_icon ? t("Clear") : t("Browse")}
onCatch={onError} </Button>
onChange={(e) => onChangeData({ sysproxy_tray_icon: e })} </SettingRow>
onGuard={(e) => patchVerge({ sysproxy_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.sysproxy_tray_icon &&
sysproxyIcon && (
<img height="20px" src={convertFileSrc(sysproxyIcon)} />
)
}
onClick={async () => {
if (verge?.sysproxy_tray_icon) {
onChangeData({ sysproxy_tray_icon: false });
patchVerge({ sysproxy_tray_icon: false });
} else {
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tray Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected) {
await copyIconFile(`${selected}`, "sysproxy");
await initIconPath();
onChangeData({ sysproxy_tray_icon: true });
patchVerge({ sysproxy_tray_icon: true });
}
}
}}
>
{verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
<Item> <SettingRow label={t("System Proxy Tray Icon")}>
<ListItemText primary={t("Tun Tray Icon")} /> <Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('sysproxy')}>
<GuardState {verge?.sysproxy_tray_icon && sysproxyIcon && <img src={convertFileSrc(sysproxyIcon)} className="h-5 mr-2" alt="system proxy tray icon"/>}
value={verge?.tun_tray_icon} {verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
onCatch={onError} </Button>
onChange={(e) => onChangeData({ tun_tray_icon: e })} </SettingRow>
onGuard={(e) => patchVerge({ tun_tray_icon: e })}
> <SettingRow label={t("Tun Tray Icon")}>
<Button <Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('tun')}>
variant="outlined" {verge?.tun_tray_icon && tunIcon && <img src={convertFileSrc(tunIcon)} className="h-5 mr-2" alt="tun mode tray icon"/>}
size="small" {verge?.tun_tray_icon ? t("Clear") : t("Browse")}
startIcon={ </Button>
verge?.tun_tray_icon && </SettingRow>
tunIcon && <img height="20px" src={convertFileSrc(tunIcon)} /> </div>
}
onClick={async () => { <DialogFooter>
if (verge?.tun_tray_icon) { <DialogClose asChild><Button type="button" variant="outline">{t("Close")}</Button></DialogClose>
onChangeData({ tun_tray_icon: false }); </DialogFooter>
patchVerge({ tun_tray_icon: false }); </DialogContent>
} else { </Dialog>
const selected = await openDialog({
directory: false,
multiple: false,
filters: [
{
name: "Tun Icon Image",
extensions: ["png", "ico"],
},
],
});
if (selected) {
await copyIconFile(`${selected}`, "tun");
await initIconPath();
onChangeData({ tun_tray_icon: true });
patchVerge({ tun_tray_icon: true });
}
}
}}
>
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
</List>
</BaseDialog>
); );
}); });
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

@@ -1,20 +1,38 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
TextField,
Typography,
InputAdornment,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { entry_lightweight_mode } from "@/services/cmds"; import { entry_lightweight_mode } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => { export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
@@ -22,7 +40,7 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [values, setValues] = useState({ const [values, setValues] = useState({
autoEnterLiteMode: false, autoEnterLiteMode: false,
autoEnterLiteModeDelay: 10, // 默认10分钟 autoEnterLiteModeDelay: 10,
}); });
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -43,103 +61,73 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
auto_light_weight_minutes: values.autoEnterLiteModeDelay, auto_light_weight_minutes: values.autoEnterLiteModeDelay,
}); });
setOpen(false); setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err.message || err.toString()); showNotice("error", err.message || err.toString());
} }
}); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={t("LightWeight Mode Settings")} <DialogHeader>
contentSx={{ width: 450 }} <DialogTitle>{t("LightWeight Mode Settings")}</DialogTitle>
okBtn={t("Save")} </DialogHeader>
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Enter LightWeight Mode Now")} />
<Typography
variant="button"
sx={{
cursor: "pointer",
color: "primary.main",
"&:hover": { textDecoration: "underline" },
}}
onClick={async () => await entry_lightweight_mode()}
>
{t("Enable")}
</Typography>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <div className="py-4 space-y-2">
<ListItemText <SettingRow label={t("Enter LightWeight Mode Now")}>
primary={t("Auto Enter LightWeight Mode")} {/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
sx={{ maxWidth: "fit-content" }} {/* Меняем variant="link" на "outline" для вида кнопки */}
/> <Button variant="outline" size="sm" onClick={entry_lightweight_mode}>
<TooltipIcon {t("Enable")}
title={t("Auto Enter LightWeight Mode Info")} </Button>
sx={{ opacity: "0.7" }} {/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
/> </SettingRow>
<Switch
edge="end"
checked={values.autoEnterLiteMode}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoEnterLiteMode: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
{values.autoEnterLiteMode && ( <SettingRow
<> label={t("Auto Enter LightWeight Mode")}
<ListItem sx={{ padding: "5px 2px" }}> extra={<TooltipIcon tooltip={t("Auto Enter LightWeight Mode Info")} />}
<ListItemText primary={t("Auto Enter LightWeight Mode Delay")} /> >
<TextField <Switch
autoComplete="off" checked={values.autoEnterLiteMode}
size="small" onCheckedChange={(c) => setValues((v) => ({ ...v, autoEnterLiteMode: c }))}
type="number" />
autoCorrect="off" </SettingRow>
autoCapitalize="off"
spellCheck="false"
sx={{ width: 150 }}
value={values.autoEnterLiteModeDelay}
onChange={(e) =>
setValues((v) => ({
...v,
autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
}))
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("mins")}
</InputAdornment>
),
},
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> {values.autoEnterLiteMode && (
<Typography <div className="pl-4">
variant="body2" <SettingRow label={t("Auto Enter LightWeight Mode Delay")}>
color="text.secondary" <div className="flex items-center gap-2">
sx={{ fontStyle: "italic" }} <Input
> type="number"
{t( className="w-24 h-8"
"When closing the window, LightWeight Mode will be automatically activated after _n minutes", value={values.autoEnterLiteModeDelay}
{ n: values.autoEnterLiteModeDelay }, onChange={(e) =>
)} setValues((v) => ({
</Typography> ...v,
</ListItem> autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
</> }))
)} }
</List> />
</BaseDialog> <span className="text-sm text-muted-foreground">{t("mins")}</span>
</div>
</SettingRow>
<p className="text-xs text-muted-foreground italic mt-2">
{t(
"When closing the window, LightWeight Mode will be automatically activated after _n minutes",
{ n: values.autoEnterLiteModeDelay }
)}
</p>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

View File

@@ -1,19 +1,40 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
List, // Новые импорты
ListItem,
ListItemText,
MenuItem,
Select,
TextField,
InputAdornment,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon"; import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
FileText, Unplug, RefreshCw, Zap, Columns, ArchiveRestore, Link as LinkIcon, Timer
} from "lucide-react";
interface Props {}
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({ label, extra, children }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2">
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>{children}</div>
</div>
);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
};
export const MiscViewer = forwardRef<DialogRef>((props, ref) => { export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -51,206 +72,110 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
try { try {
await patchVerge({ await patchVerge({
app_log_level: values.appLogLevel, app_log_level: values.appLogLevel as any,
auto_close_connection: values.autoCloseConnection, auto_close_connection: values.autoCloseConnection,
auto_check_update: values.autoCheckUpdate, auto_check_update: values.autoCheckUpdate,
enable_builtin_enhanced: values.enableBuiltinEnhanced, enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn, proxy_layout_column: Number(values.proxyLayoutColumn),
default_latency_test: values.defaultLatencyTest, default_latency_test: values.defaultLatencyTest,
default_latency_timeout: values.defaultLatencyTimeout, default_latency_timeout: Number(values.defaultLatencyTimeout),
auto_log_clean: values.autoLogClean as any, auto_log_clean: values.autoLogClean as any,
}); });
setOpen(false); setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err.toString()); showNotice("error", err.toString());
} }
}); });
const handleValueChange = (key: keyof typeof values, value: any) => {
setValues(v => ({ ...v, [key]: value }));
};
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-2xl">
title={t("Miscellaneous")} <DialogHeader>
contentSx={{ width: 450 }} <DialogTitle>{t("Miscellaneous")}</DialogTitle>
okBtn={t("Save")} </DialogHeader>
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("App Log Level")} />
<Select
size="small"
sx={{ width: 100, "> div": { py: "7.5px" } }}
value={values.appLogLevel}
onChange={(e) =>
setValues((v) => ({
...v,
appLogLevel: e.target.value as string,
}))
}
>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<MenuItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <div className="max-h-[70vh] overflow-y-auto px-1 space-y-1">
<ListItemText <SettingRow label={<LabelWithIcon icon={FileText} text={t("App Log Level")} />}>
primary={t("Auto Close Connections")} <Select value={values.appLogLevel} onValueChange={(v) => handleValueChange("appLogLevel", v)}>
sx={{ maxWidth: "fit-content" }} <SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
/> <SelectContent>
<TooltipIcon {["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
title={t("Auto Close Connections Info")} <SelectItem value={i} key={i}>{i[0].toUpperCase() + i.slice(1).toLowerCase()}</SelectItem>
sx={{ opacity: "0.7" }} ))}
/> </SelectContent>
<Switch </Select>
edge="end" </SettingRow>
checked={values.autoCloseConnection}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCloseConnection: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={Unplug} text={t("Auto Close Connections")} />} extra={<TooltipIcon tooltip={t("Auto Close Connections Info")} />}>
<ListItemText primary={t("Auto Check Update")} /> <Switch checked={values.autoCloseConnection} onCheckedChange={(c) => handleValueChange("autoCloseConnection", c)} />
<Switch </SettingRow>
edge="end"
checked={values.autoCheckUpdate}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCheckUpdate: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={RefreshCw} text={t("Auto Check Update")} />}>
<ListItemText <Switch checked={values.autoCheckUpdate} onCheckedChange={(c) => handleValueChange("autoCheckUpdate", c)} />
primary={t("Enable Builtin Enhanced")} </SettingRow>
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Enable Builtin Enhanced Info")}
sx={{ opacity: "0.7" }}
/>
<Switch
edge="end"
checked={values.enableBuiltinEnhanced}
onChange={(_, c) =>
setValues((v) => ({ ...v, enableBuiltinEnhanced: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={Zap} text={t("Enable Builtin Enhanced")} />} extra={<TooltipIcon tooltip={t("Enable Builtin Enhanced Info")} />}>
<ListItemText primary={t("Proxy Layout Columns")} /> <Switch checked={values.enableBuiltinEnhanced} onCheckedChange={(c) => handleValueChange("enableBuiltinEnhanced", c)} />
<Select </SettingRow>
size="small"
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.proxyLayoutColumn}
onChange={(e) =>
setValues((v) => ({
...v,
proxyLayoutColumn: e.target.value as number,
}))
}
>
<MenuItem value={6} key={6}>
{t("Auto Columns")}
</MenuItem>
{[1, 2, 3, 4, 5].map((i) => (
<MenuItem value={i} key={i}>
{i}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={Columns} text={t("Proxy Layout Columns")} />}>
<ListItemText primary={t("Auto Log Clean")} /> <Select value={String(values.proxyLayoutColumn)} onValueChange={(v) => handleValueChange("proxyLayoutColumn", Number(v))}>
<Select <SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
size="small" <SelectContent>
sx={{ width: 135, "> div": { py: "7.5px" } }} <SelectItem value="6">{t("Auto Columns")}</SelectItem>
value={values.autoLogClean} {[1, 2, 3, 4, 5].map((i) => (<SelectItem value={String(i)} key={i}>{i}</SelectItem>))}
onChange={(e) => </SelectContent>
setValues((v) => ({ </Select>
...v, </SettingRow>
autoLogClean: e.target.value as number,
}))
}
>
{[
{ key: t("Never Clean"), value: 0 },
{ key: t("Retain _n Days", { n: 1 }), value: 1 },
{ key: t("Retain _n Days", { n: 7 }), value: 2 },
{ key: t("Retain _n Days", { n: 30 }), value: 3 },
{ key: t("Retain _n Days", { n: 90 }), value: 4 },
].map((i) => (
<MenuItem key={i.value} value={i.value}>
{i.key}
</MenuItem>
))}
</Select>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={ArchiveRestore} text={t("Auto Log Clean")} />}>
<ListItemText <Select value={String(values.autoLogClean)} onValueChange={(v) => handleValueChange("autoLogClean", Number(v))}>
primary={t("Default Latency Test")} <SelectTrigger className="w-48 h-8"><SelectValue /></SelectTrigger>
sx={{ maxWidth: "fit-content" }} <SelectContent>
/> {[
<TooltipIcon { key: t("Never Clean"), value: 0 },
title={t("Default Latency Test Info")} { key: t("Retain _n Days", { n: 1 }), value: 1 },
sx={{ opacity: "0.7" }} { key: t("Retain _n Days", { n: 7 }), value: 2 },
/> { key: t("Retain _n Days", { n: 30 }), value: 3 },
<TextField { key: t("Retain _n Days", { n: 90 }), value: 4 },
autoComplete="new-password" ].map((i) => (<SelectItem key={i.value} value={String(i.value)}>{i.key}</SelectItem>))}
size="small" </SelectContent>
autoCorrect="off" </Select>
autoCapitalize="off" </SettingRow>
spellCheck="false"
sx={{ width: 250, marginLeft: "auto" }}
value={values.defaultLatencyTest}
placeholder="https://cp.cloudflare.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <SettingRow label={<LabelWithIcon icon={LinkIcon} text={t("Default Latency Test")} />} extra={<TooltipIcon tooltip={t("Default Latency Test Info")} />}>
<ListItemText primary={t("Default Latency Timeout")} /> <Input
<TextField className="w-75 h-8"
autoComplete="new-password" value={values.defaultLatencyTest}
size="small" placeholder="https://www.google.com/generate_204"
type="number" onChange={(e) => handleValueChange("defaultLatencyTest", e.target.value)}
autoCorrect="off" />
autoCapitalize="off" </SettingRow>
spellCheck="false"
sx={{ width: 250 }} <SettingRow label={<LabelWithIcon icon={Timer} text={t("Default Latency Timeout")} />}>
value={values.defaultLatencyTimeout} <div className="flex items-center gap-2">
placeholder="10000" <Input
onChange={(e) => type="number"
setValues((v) => ({ className="w-24 h-8"
...v, value={values.defaultLatencyTimeout}
defaultLatencyTimeout: parseInt(e.target.value), placeholder="5000"
})) onChange={(e) => handleValueChange("defaultLatencyTimeout", Number(e.target.value))}
} />
slotProps={{ <span className="text-sm text-muted-foreground">{t("millis")}</span>
input: { </div>
endAdornment: ( </SettingRow>
<InputAdornment position="end">{t("millis")}</InputAdornment> </div>
),
}, <DialogFooter>
}} <DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
/> <Button type="button" onClick={onSave}>{t("Save")}</Button>
</ListItem> </DialogFooter>
</List> </DialogContent>
</BaseDialog> </Dialog>
); );
}); });

View File

@@ -1,140 +1,117 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base"; import { useLockFn } from "ahooks";
import { getNetworkInterfacesInfo } from "@/services/cmds"; import useSWR from "swr";
import { alpha, Box, Button, IconButton } from "@mui/material";
import { ContentCopyRounded } from "@mui/icons-material";
import { writeText } from "@tauri-apps/plugin-clipboard-manager"; import { writeText } from "@tauri-apps/plugin-clipboard-manager";
// Новые импорты
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { DialogRef } from "@/components/base";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Copy } from "lucide-react";
// Дочерний компонент AddressDisplay (без изменений)
const AddressDisplay = (props: { label: string; content: string }) => {
const { t } = useTranslation();
const handleCopy = useLockFn(async () => {
if (!props.content) return;
await writeText(props.content);
showNotice("success", t("Copy Success"));
});
return (
<div className="flex justify-between items-center text-sm my-2">
<p className="text-muted-foreground">{props.label}</p>
<div className="flex items-center gap-2 rounded-md bg-muted px-2 py-1">
<span className="font-mono">{props.content}</span>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={handleCopy}>
<Copy className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
};
export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => { export const NetworkInterfaceViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [networkInterfaces, setNetworkInterfaces] = useState<
INetworkInterface[]
>([]);
const [isV4, setIsV4] = useState(true); const [isV4, setIsV4] = useState(true);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => setOpen(true),
setOpen(true);
},
close: () => setOpen(false), close: () => setOpen(false),
})); }));
useEffect(() => { const { data: networkInterfaces } = useSWR(
if (!open) return; open ? "clash-verge-rev-internal://network-interfaces" : null,
getNetworkInterfacesInfo().then((res) => { getNetworkInterfacesInfo,
setNetworkInterfaces(res); { fallbackData: [] }
}); );
}, [open]);
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-lg">
title={ <DialogHeader>
<Box display="flex" justifyContent="space-between"> <div className="flex justify-between items-center pr-12">
{t("Network Interface")} <DialogTitle>{t("Network Interface")}</DialogTitle>
<Box> <div className="flex items-center rounded-md border bg-muted p-0.5">
<Button {/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
variant="contained" {/* Меняем `secondary` на `default` для активной кнопки */}
size="small" <Button variant={isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(true)}>IPv4</Button>
onClick={() => { <Button variant={!isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(false)}>IPv6</Button>
setIsV4((prev) => !prev); {/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
}} </div>
> </div>
{isV4 ? "Ipv6" : "Ipv4"} </DialogHeader>
</Button>
</Box> <div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
</Box> {networkInterfaces?.map((item, index) => (
} <div key={item.name} className="py-2">
contentSx={{ width: 450 }} <h4 className="font-semibold text-base mb-1">{item.name}</h4>
disableOk <div>
cancelBtn={t("Close")} {isV4 ? (
onCancel={() => setOpen(false)} <>
> {item.addr.map((address) => address.V4 && <AddressDisplay key={address.V4.ip} label={t("Ip Address")} content={address.V4.ip} />)}
{networkInterfaces.map((item) => ( <AddressDisplay label={t("Mac Address")} content={item.mac_addr ?? ""} />
<Box key={item.name}> </>
<h4>{item.name}</h4> ) : (
<Box> <>
{isV4 && ( {item.addr.map((address) => address.V6 && <AddressDisplay key={address.V6.ip} label={t("Ip Address")} content={address.V6.ip} />)}
<> <AddressDisplay label={t("Mac Address")} content={item.mac_addr ?? ""} />
{item.addr.map( </>
(address) =>
address.V4 && (
<AddressDisplay
key={address.V4.ip}
label={t("Ip Address")}
content={address.V4.ip}
/>
),
)} )}
<AddressDisplay </div>
label={t("Mac Address")} {index < networkInterfaces.length - 1 && <Separator className="mt-2"/>}
content={item.mac_addr ?? ""} </div>
/> ))}
</> </div>
)}
{!isV4 && ( <DialogFooter>
<> <DialogClose asChild>
{item.addr.map( <Button type="button" variant="outline">{t("Close")}</Button>
(address) => </DialogClose>
address.V6 && ( </DialogFooter>
<AddressDisplay </DialogContent>
key={address.V6.ip} </Dialog>
label={t("Ip Address")}
content={address.V6.ip}
/>
),
)}
<AddressDisplay
label={t("Mac Address")}
content={item.mac_addr ?? ""}
/>
</>
)}
</Box>
</Box>
))}
</BaseDialog>
); );
}); });
const AddressDisplay = (props: { label: string; content: string }) => {
const { t } = useTranslation();
return (
<Box
sx={{
display: "flex",
justifyContent: "space-between",
margin: "8px 0",
}}
>
<Box>{props.label}</Box>
<Box
sx={({ palette }) => ({
borderRadius: "8px",
padding: "2px 2px 2px 8px",
background:
palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: alpha(palette.grey[400], 0.3),
})}
>
<Box sx={{ display: "inline", userSelect: "text" }}>
{props.content}
</Box>
<IconButton
size="small"
onClick={async () => {
await writeText(props.content);
showNotice("success", t("Copy Success"));
}}
>
<ContentCopyRounded sx={{ fontSize: "18px" }} />
</IconButton>
</Box>
</Box>
);
};

View File

@@ -1,54 +1,75 @@
import { useEffect, useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Новые импорты
import { import {
Button, AlertDialog,
Dialog, AlertDialogAction,
DialogActions, AlertDialogContent,
DialogContent, AlertDialogDescription,
DialogTitle, AlertDialogFooter,
TextField, AlertDialogHeader,
} from "@mui/material"; AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
interface Props { interface Props {
// Компонент теперь сам управляет своим состоянием,
// но вызывает onConfirm при подтверждении
onConfirm: (passwd: string) => Promise<void>; onConfirm: (passwd: string) => Promise<void>;
// onCancel?: () => void; // Можно добавить, если нужна кнопка отмены
} }
export const PasswordInput = (props: Props) => { export const PasswordInput = (props: Props) => {
const { onConfirm } = props; const { onConfirm } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const [passwd, setPasswd] = useState(""); const [passwd, setPasswd] = useState("");
useEffect(() => { const handleSubmit = async (event?: React.FormEvent) => {
if (!open) return; // Предотвращаем стандартную отправку формы
}, [open]); event?.preventDefault();
await onConfirm(passwd);
};
return ( return (
<Dialog open={true} maxWidth="xs" fullWidth> // Этот диалог будет открыт всегда, пока он отрендерен на странице
<DialogTitle>{t("Please enter your root password")}</DialogTitle> <AlertDialog open={true}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Please enter your root password")}</AlertDialogTitle>
<AlertDialogDescription>
{t("This action requires administrator privileges.")}
</AlertDialogDescription>
</AlertDialogHeader>
<DialogContent> <form onSubmit={handleSubmit}>
<TextField <div className="py-4">
sx={{ mt: 1 }} <Label htmlFor="password-input">{t("Password")}</Label>
autoFocus <Input
label={t("Password")} id="password-input"
fullWidth type="password"
size="small" autoFocus
type="password" value={passwd}
value={passwd} onChange={(e) => setPasswd(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onConfirm(passwd)} className="mt-2"
onChange={(e) => setPasswd(e.target.value)} />
></TextField> </div>
</DialogContent> {/* Скрытая кнопка для того, чтобы Enter в поле ввода вызывал onSubmit */}
<button type="submit" className="hidden" />
</form>
<DialogActions> <AlertDialogFooter>
<Button {/* У этого диалога нет кнопки отмены */}
onClick={async () => await onConfirm(passwd)} <AlertDialogAction asChild>
variant="contained" <Button type="button" onClick={handleSubmit}>
> {t("Confirm")}
{t("Confirm")} </Button>
</Button> </AlertDialogAction>
</DialogActions> </AlertDialogFooter>
</Dialog> </AlertDialogContent>
</AlertDialog>
); );
}; };

View File

@@ -1,21 +1,32 @@
import React, { ReactNode, useState } from "react"; import React, { ReactNode, useState } from "react";
import {
Box,
List,
ListItem,
ListItemButton,
ListItemText,
ListSubheader,
} from "@mui/material";
import { ChevronRightRounded } from "@mui/icons-material";
import CircularProgress from "@mui/material/CircularProgress";
import isAsyncFunction from "@/utils/is-async-function"; import isAsyncFunction from "@/utils/is-async-function";
import { cn } from "@root/lib/utils";
// Новые импорты
import { Loader2, ChevronRight } from "lucide-react";
// --- Новый компонент SettingList ---
interface ListProps {
title: string;
children: ReactNode;
}
export const SettingList: React.FC<ListProps> = ({ title, children }) => (
<div>
<h3 className="text-lg font-medium mb-4 px-1">{title}</h3>
<div className="flex flex-col">
{children}
</div>
</div>
);
// --- Новый компонент SettingItem ---
interface ItemProps { interface ItemProps {
label: ReactNode; label: ReactNode;
extra?: ReactNode; extra?: ReactNode; // Для иконок-подсказок рядом с лейблом
children?: ReactNode; children?: ReactNode; // Для элементов управления (Switch, Select и т.д.)
secondary?: ReactNode; secondary?: ReactNode; // Для текста-описания под лейблом
onClick?: () => void | Promise<any>; onClick?: () => void | Promise<any>;
} }
@@ -23,16 +34,11 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children, secondary, onClick } = props; const { label, extra, children, secondary, onClick } = props;
const clickable = !!onClick; const clickable = !!onClick;
const primary = (
<Box sx={{ display: "flex", alignItems: "center", fontSize: "14px" }}>
<span>{label}</span>
{extra ? extra : null}
</Box>
);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const handleClick = () => { const handleClick = () => {
if (onClick) { if (onClick) {
// Если onClick - асинхронная функция, показываем спиннер
if (isAsyncFunction(onClick)) { if (isAsyncFunction(onClick)) {
setIsLoading(true); setIsLoading(true);
onClick()!.finally(() => setIsLoading(false)); onClick()!.finally(() => setIsLoading(false));
@@ -42,44 +48,34 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
} }
}; };
return clickable ? ( return (
<ListItem disablePadding> <div
<ListItemButton onClick={handleClick} disabled={isLoading}> onClick={clickable ? handleClick : undefined}
<ListItemText primary={primary} secondary={secondary} /> className={cn(
"flex items-center justify-between py-4 border-b border-border last:border-b-0",
clickable && "cursor-pointer hover:bg-accent/50 -mx-4 px-4",
isLoading && "cursor-default opacity-70"
)}
>
{/* Левая часть: заголовок и описание */}
<div className="flex flex-col gap-1 pr-4">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{label}</p>
{extra}
</div>
{secondary && <p className="text-sm text-muted-foreground">{secondary}</p>}
</div>
{/* Правая часть: элемент управления или иконка */}
<div className="flex-shrink-0">
{isLoading ? ( {isLoading ? (
<CircularProgress color="inherit" size={20} /> <Loader2 className="h-5 w-5 animate-spin" />
) : clickable ? (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
) : ( ) : (
<ChevronRightRounded /> children
)} )}
</ListItemButton> </div>
</ListItem> </div>
) : (
<ListItem sx={{ pt: "5px", pb: "5px" }}>
<ListItemText primary={primary} secondary={secondary} />
{children}
</ListItem>
); );
}; };
export const SettingList: React.FC<{
title: string;
children: ReactNode;
}> = (props) => (
<List>
<ListSubheader
sx={[
{ background: "transparent", fontSize: "16px", fontWeight: "700" },
({ palette }) => {
return {
color: palette.text.primary,
};
},
]}
disableSticky
>
{props.title}
</ListSubheader>
{props.children}
</List>
);

View File

@@ -1,36 +1,37 @@
import { Button, ButtonGroup } from "@mui/material"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
// Определяем возможные значения для TypeScript
type StackMode = "system" | "gvisor" | "mixed";
interface Props { interface Props {
value?: string; value?: string;
onChange?: (value: string) => void; onChange?: (value: StackMode) => void;
} }
export const StackModeSwitch = (props: Props) => { export const StackModeSwitch = (props: Props) => {
const { value, onChange } = props; const { value, onChange } = props;
const { t } = useTranslation();
// Массив с опциями для удобного рендеринга
const modes: StackMode[] = ["system", "gvisor", "mixed"];
return ( return (
<ButtonGroup size="small" sx={{ my: "4px" }}> // Используем наш стандартный контейнер для создания группы кнопок
<Button <div className="flex items-center rounded-md border bg-muted p-0.5">
variant={value?.toLowerCase() === "system" ? "contained" : "outlined"} {modes.map((mode) => (
onClick={() => onChange?.("system")} <Button
sx={{ textTransform: "capitalize" }} key={mode}
> // Активная кнопка получает основной цвет темы
System variant={value?.toLowerCase() === mode ? "default" : "ghost"}
</Button> onClick={() => onChange?.(mode)}
<Button size="sm"
variant={value?.toLowerCase() === "gvisor" ? "contained" : "outlined"} className="capitalize px-3 text-xs"
onClick={() => onChange?.("gvisor")} >
sx={{ textTransform: "capitalize" }} {/* Используем t() для возможной локализации в будущем */}
> {t(mode)}
gVisor </Button>
</Button> ))}
<Button </div>
variant={value?.toLowerCase() === "mixed" ? "contained" : "outlined"}
onClick={() => onChange?.("mixed")}
sx={{ textTransform: "capitalize" }}
>
Mixed
</Button>
</ButtonGroup>
); );
}; };

View File

@@ -1,6 +1,12 @@
import { BaseDialog, DialogRef, Switch } from "@/components/base"; import { forwardRef, useImperativeHandle, useState, useMemo, useEffect, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import useSWR, { mutate } from "swr";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { BaseFieldset } from "@/components/base/base-fieldset"; import { BaseFieldset } from "@/components/base/base-fieldset";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { EditorViewer } from "@/components/profile/editor-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider"; import { useAppData } from "@/providers/app-data-provider";
@@ -14,66 +20,71 @@ import {
} from "@/services/cmds"; } from "@/services/cmds";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system"; import getSystem from "@/utils/get-system";
import { EditRounded } from "@mui/icons-material"; import { Button } from "@/components/ui/button";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
Autocomplete, import { Input } from "@/components/ui/input";
Button, import { Label } from "@/components/ui/label";
InputAdornment, import { Textarea } from "@/components/ui/textarea";
List, import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
ListItem, import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
ListItemText, import { Check, ChevronsUpDown, Edit, Loader2 } from "lucide-react";
styled, import { cn } from "@root/lib/utils";
TextField, import { TooltipIcon } from "@/components/base/base-tooltip-icon";
Typography,
} from "@mui/material";
import { useLockFn } from "ahooks";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
const DEFAULT_PAC = `function FindProxyForURL(url, host) {
return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;";
}`;
/** NO_PROXY validation */
// *., cdn*., *, etc.
const domain_subdomain_part = String.raw`(?:[a-z0-9\-\*]+\.|\*)*`;
// .*, .cn, .moe, .co*, *
const domain_tld_part = String.raw`(?:\w{2,64}\*?|\*)`;
// *epicgames*, *skk.moe, *.skk.moe, skk.*, sponsor.cdn.skk.moe, *.*, etc.
// also matches 192.168.*, 10.*, 127.0.0.*, etc. (partial ipv4)
const rDomainSimple = domain_subdomain_part + domain_tld_part;
// --- Вся ваша оригинальная логика, константы и хелперы ---
const DEFAULT_PAC = `function FindProxyForURL(url, host) { return "PROXY %proxy_host%:%mixed-port%; SOCKS5 %proxy_host%:%mixed-port%; DIRECT;"; }`;
const ipv4_part = String.raw`\d{1,3}`; const ipv4_part = String.raw`\d{1,3}`;
const rDomainSimple = String.raw`(?:[a-z0-9\-\*]+\.|\*)*` + String.raw`(?:\w{2,64}\*?|\*)`;
const ipv6_part = "(?:[a-fA-F0-9:])+"; const ipv6_part = "(?:[a-fA-F0-9:])+";
const rLocal = `localhost|<local>|localdomain`; const rLocal = `localhost|<local>|localdomain`;
const getValidReg = (isWindows: boolean) => { const getValidReg = (isWindows: boolean) => {
// 127.0.0.1 (full ipv4)
const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`; const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`;
const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`; const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`;
const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`; const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`;
const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}`; const rIPv6Windows = String.raw`(?:${ipv6_part}:+)+${ipv6_part}`;
const rValidPart = `${rDomainSimple}|${isWindows ? rIPv4Windows : rIPv4Unix}|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`;
const rValidPart = `${rDomainSimple}|${
isWindows ? rIPv4Windows : rIPv4Unix
}|${isWindows ? rIPv6Windows : rIPv6Unix}|${rLocal}`;
const separator = isWindows ? ";" : ","; const separator = isWindows ? ";" : ",";
const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`; const rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`;
return new RegExp(rValid); return new RegExp(rValid);
}; };
// --- Компонент Combobox для замены Autocomplete ---
const Combobox = ({ options, value, onValueChange, placeholder }: { options: string[], value: string, onValueChange: (value: string) => void, placeholder?: string }) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen} modal={true}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open} className="w-48 h-8 justify-between font-normal">
{value || placeholder || "Select..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command onValueChange={onValueChange}>
<CommandInput placeholder="Search or type..." />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{options.map((option) => (
<CommandItem key={option} value={option} onSelect={(currentValue) => { onValueChange(options.find(opt => opt.toLowerCase() === currentValue) || ''); setOpen(false); }}>
<Check className={cn("mr-2 h-4 w-4", value === option ? "opacity-100" : "opacity-0")} />
{option}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
// --- Наш переиспользуемый компонент для строки настроек ---
const SettingRow = ({ label, children }: { label: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-2">
<Label className="text-sm text-muted-foreground flex items-center gap-2">{label}</Label>
<div>{children}</div>
</div>
);
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => { export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const isWindows = getSystem() === "windows"; const isWindows = getSystem() === "windows";
@@ -91,57 +102,25 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
type AutoProxy = Awaited<ReturnType<typeof getAutotemProxy>>; type AutoProxy = Awaited<ReturnType<typeof getAutotemProxy>>;
const [autoproxy, setAutoproxy] = useState<AutoProxy>(); const [autoproxy, setAutoproxy] = useState<AutoProxy>();
const { const { enable_system_proxy: enabled, proxy_auto_config, pac_file_content, enable_proxy_guard, use_default_bypass, system_proxy_bypass, proxy_guard_duration, proxy_host } = verge ?? {};
enable_system_proxy: enabled,
proxy_auto_config,
pac_file_content,
enable_proxy_guard,
use_default_bypass,
system_proxy_bypass,
proxy_guard_duration,
proxy_host,
} = verge ?? {};
const [value, setValue] = useState({ const [value, setValue] = useState({
guard: enable_proxy_guard, guard: enable_proxy_guard, bypass: system_proxy_bypass, duration: proxy_guard_duration ?? 10,
bypass: system_proxy_bypass, use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? "127.0.0.1", proxy_host: proxy_host ?? "127.0.0.1",
}); });
const defaultBypass = () => { const defaultBypass = () => {
if (isWindows) { if (isWindows) return "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>";
return "localhost;127.*;192.168.*;10.*;172.16.*;172.17.*;172.18.*;172.19.*;172.20.*;172.21.*;172.22.*;172.23.*;172.24.*;172.25.*;172.26.*;172.27.*;172.28.*;172.29.*;172.30.*;172.31.*;<local>"; if (getSystem() === "linux") return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1";
}
if (getSystem() === "linux") {
return "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,::1";
}
return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>"; return "127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,172.29.0.0/16,localhost,*.local,*.crashlytics.com,<local>";
}; };
const { data: clashConfig, mutate: mutateClash } = useSWR( const { data: clashConfig } = useSWR("getClashConfig", getClashConfig, { revalidateOnFocus: false, revalidateIfStale: true, dedupingInterval: 1000, errorRetryInterval: 5000 });
"getClashConfig", const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.["mixed-port"]);
getClashConfig,
{
revalidateOnFocus: false,
revalidateIfStale: true,
dedupingInterval: 1000,
errorRetryInterval: 5000,
},
);
const [prevMixedPort, setPrevMixedPort] = useState(
clashConfig?.["mixed-port"],
);
useEffect(() => { useEffect(() => {
if ( if (clashConfig?.["mixed-port"] && clashConfig?.["mixed-port"] !== prevMixedPort) {
clashConfig?.["mixed-port"] &&
clashConfig?.["mixed-port"] !== prevMixedPort
) {
setPrevMixedPort(clashConfig?.["mixed-port"]); setPrevMixedPort(clashConfig?.["mixed-port"]);
resetSystemProxy(); resetSystemProxy();
} }
@@ -151,36 +130,20 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
try { try {
const currentSysProxy = await getSystemProxy(); const currentSysProxy = await getSystemProxy();
const currentAutoProxy = await getAutotemProxy(); const currentAutoProxy = await getAutotemProxy();
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) { if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
// 临时关闭系统代理
await patchVergeConfig({ enable_system_proxy: false }); await patchVergeConfig({ enable_system_proxy: false });
// 减少等待时间
await new Promise((resolve) => setTimeout(resolve, 200)); await new Promise((resolve) => setTimeout(resolve, 200));
// 重新开启系统代理
await patchVergeConfig({ enable_system_proxy: true }); await patchVergeConfig({ enable_system_proxy: true });
await Promise.all([ mutate("getSystemProxy"), mutate("getAutotemProxy") ]);
// 更新UI状态
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
} }
} catch (err: any) { } catch (err: any) { showNotice("error", err.toString()); }
showNotice("error", err.toString());
}
}; };
const { systemProxyAddress } = useAppData(); const { systemProxyAddress } = useAppData();
// 为当前状态计算系统代理地址
const getSystemProxyAddress = useMemo(() => { const getSystemProxyAddress = useMemo(() => {
if (!clashConfig) return "-"; if (!clashConfig) return "-";
const isPacMode = value.pac ?? false; const isPacMode = value.pac ?? false;
if (isPacMode) { if (isPacMode) {
const host = value.proxy_host || "127.0.0.1"; const host = value.proxy_host || "127.0.0.1";
const port = verge?.verge_mixed_port || clashConfig["mixed-port"] || 7897; const port = verge?.verge_mixed_port || clashConfig["mixed-port"] || 7897;
@@ -188,448 +151,160 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} else { } else {
return systemProxyAddress; return systemProxyAddress;
} }
}, [ }, [value.pac, value.proxy_host, verge?.verge_mixed_port, clashConfig, systemProxyAddress]);
value.pac,
value.proxy_host,
verge?.verge_mixed_port,
clashConfig,
systemProxyAddress,
]);
const getCurrentPacUrl = useMemo(() => { const getCurrentPacUrl = useMemo(() => {
const host = value.proxy_host || "127.0.0.1"; const host = value.proxy_host || "127.0.0.1";
// 根据环境判断PAC端口
const port = import.meta.env.DEV ? 11233 : 33331; const port = import.meta.env.DEV ? 11233 : 33331;
return `http://${host}:${port}/commands/pac`; return `http://${host}:${port}/commands/pac`;
}, [value.proxy_host]); }, [value.proxy_host]);
const fetchNetworkInterfaces = async () => {
try {
const interfaces = await getNetworkInterfacesInfo();
const ipAddresses: string[] = [];
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4 && address.V4.ip) ipAddresses.push(address.V4.ip);
if (address.V6 && address.V6.ip) ipAddresses.push(address.V6.ip);
});
});
let hostname = "";
try {
hostname = await getSystemHostname();
if (hostname && hostname !== "localhost" && hostname !== "127.0.0.1") {
hostname = hostname + ".local";
}
} catch (err) { console.error("Failed to get hostname:", err); }
const options = ["127.0.0.1", "localhost"];
if (hostname) options.push(hostname);
options.push(...ipAddresses);
setHostOptions(Array.from(new Set(options)));
} catch (error) {
console.error("Failed to get network interfaces:", error);
setHostOptions(["127.0.0.1", "localhost"]);
}
};
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
open: () => { open: () => {
setOpen(true); setOpen(true);
setValue({ setValue({
guard: enable_proxy_guard, guard: enable_proxy_guard, bypass: system_proxy_bypass, duration: proxy_guard_duration ?? 10,
bypass: system_proxy_bypass, use_default: use_default_bypass ?? true, pac: proxy_auto_config, pac_content: pac_file_content ?? DEFAULT_PAC,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
proxy_host: proxy_host ?? "127.0.0.1", proxy_host: proxy_host ?? "127.0.0.1",
}); });
getSystemProxy().then((p) => setSysproxy(p)); getSystemProxy().then(setSysproxy);
getAutotemProxy().then((p) => setAutoproxy(p)); getAutotemProxy().then(setAutoproxy);
fetchNetworkInterfaces(); fetchNetworkInterfaces();
}, },
close: () => setOpen(false), close: () => setOpen(false),
})); }));
// 获取网络接口和主机名
const fetchNetworkInterfaces = async () => {
try {
// 获取系统网络接口信息
const interfaces = await getNetworkInterfacesInfo();
const ipAddresses: string[] = [];
// 从interfaces中提取IPv4和IPv6地址
interfaces.forEach((iface) => {
iface.addr.forEach((address) => {
if (address.V4 && address.V4.ip) {
ipAddresses.push(address.V4.ip);
}
if (address.V6 && address.V6.ip) {
ipAddresses.push(address.V6.ip);
}
});
});
// 获取当前系统的主机名
let hostname = "";
try {
hostname = await getSystemHostname();
console.log("获取到主机名:", hostname);
} catch (err) {
console.error("获取主机名失败:", err);
}
// 构建选项列表
const options = ["127.0.0.1", "localhost"];
// 确保主机名添加到列表,即使它是空字符串也记录下来
if (hostname) {
// 如果主机名不是localhost或127.0.0.1,则添加它
if (hostname !== "localhost" && hostname !== "127.0.0.1") {
hostname = hostname + ".local";
options.push(hostname);
console.log("主机名已添加到选项中:", hostname);
} else {
console.log("主机名与已有选项重复:", hostname);
}
} else {
console.log("主机名为空");
}
// 添加IP地址
options.push(...ipAddresses);
// 去重
const uniqueOptions = Array.from(new Set(options));
console.log("最终选项列表:", uniqueOptions);
setHostOptions(uniqueOptions);
} catch (error) {
console.error("获取网络接口失败:", error);
// 失败时至少提供基本选项
setHostOptions(["127.0.0.1", "localhost"]);
}
};
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
if (value.duration < 1) { if (value.duration < 1) { showNotice("error", t("Proxy Daemon Duration Cannot be Less than 1 Second")); return; }
showNotice( if (value.bypass && !validReg.test(value.bypass)) { showNotice("error", t("Invalid Bypass Format")); return; }
"error", const ipv4Regex = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
t("Proxy Daemon Duration Cannot be Less than 1 Second"), const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
); const hostnameRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][a-zA-Z0-9\-]*[A-Za-z0-9])$/;
return; if (!ipv4Regex.test(value.proxy_host) && !ipv6Regex.test(value.proxy_host) && !hostnameRegex.test(value.proxy_host)) { showNotice("error", t("Invalid Proxy Host Format")); return; }
}
if (value.bypass && !validReg.test(value.bypass)) {
showNotice("error", t("Invalid Bypass Format"));
return;
}
// 修改验证规则允许IP和主机名
const ipv4Regex =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const ipv6Regex =
/^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/;
const hostnameRegex =
/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/;
if (
!ipv4Regex.test(value.proxy_host) &&
!ipv6Regex.test(value.proxy_host) &&
!hostnameRegex.test(value.proxy_host)
) {
showNotice("error", t("Invalid Proxy Host Format"));
return;
}
setSaving(true); setSaving(true);
setOpen(false); let proxyHost = value.proxy_host;
setSaving(false); if (ipv6Regex.test(proxyHost) && !proxyHost.startsWith("[") && !proxyHost.endsWith("]")) { proxyHost = `[${proxyHost}]`; }
const patch: Partial<IVergeConfig> = {};
if (value.guard !== enable_proxy_guard) {
patch.enable_proxy_guard = value.guard;
}
if (value.duration !== proxy_guard_duration) {
patch.proxy_guard_duration = value.duration;
}
if (value.bypass !== system_proxy_bypass) {
patch.system_proxy_bypass = value.bypass;
}
if (value.pac !== proxy_auto_config) {
patch.proxy_auto_config = value.pac;
}
if (value.use_default !== use_default_bypass) {
patch.use_default_bypass = value.use_default;
}
const patch: Partial<IVergeConfig> = {
enable_proxy_guard: value.guard,
proxy_guard_duration: value.duration,
system_proxy_bypass: value.bypass,
proxy_auto_config: value.pac,
use_default_bypass: value.use_default,
proxy_host: proxyHost,
};
let pacContent = value.pac_content; let pacContent = value.pac_content;
if (pacContent) { if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host); pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host);
// 将 mixed-port 转换为字符串
const mixedPortStr = (clashConfig?.["mixed-port"] || "").toString(); const mixedPortStr = (clashConfig?.["mixed-port"] || "").toString();
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr); pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr);
} }
patch.pac_file_content = pacContent;
if (pacContent !== pac_file_content) { try {
patch.pac_file_content = pacContent; await patchVerge(patch);
setTimeout(() => {
if (enabled) resetSystemProxy();
}, 50);
} catch (err: any) {
showNotice("error", err.toString());
} finally {
setSaving(false);
setOpen(false);
} }
// 处理IPv6地址如果是IPv6地址但没有被方括号包围则添加方括号
let proxyHost = value.proxy_host;
if (
ipv6Regex.test(proxyHost) &&
!proxyHost.startsWith("[") &&
!proxyHost.endsWith("]")
) {
proxyHost = `[${proxyHost}]`;
}
if (proxyHost !== proxy_host) {
patch.proxy_host = proxyHost;
}
// 判断是否需要重置系统代理
const needResetProxy =
value.pac !== proxy_auto_config ||
proxyHost !== proxy_host ||
pacContent !== pac_file_content ||
value.bypass !== system_proxy_bypass ||
value.use_default !== use_default_bypass;
Promise.resolve().then(async () => {
try {
// 乐观更新本地状态
if (Object.keys(patch).length > 0) {
mutateVerge({ ...verge, ...patch }, false);
}
if (Object.keys(patch).length > 0) {
await patchVerge(patch);
}
setTimeout(async () => {
try {
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
// 如果需要重置代理且代理当前启用
if (needResetProxy && enabled) {
const [currentSysProxy, currentAutoProxy] = await Promise.all([
getSystemProxy(),
getAutotemProxy(),
]);
const isProxyActive = value.pac
? currentAutoProxy?.enable
: currentSysProxy?.enable;
if (isProxyActive) {
await patchVergeConfig({ enable_system_proxy: false });
await new Promise((resolve) => setTimeout(resolve, 50));
await patchVergeConfig({ enable_system_proxy: true });
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
}
}
} catch (err) {
console.warn("代理状态更新失败:", err);
}
}, 50);
} catch (err: any) {
console.error("配置保存失败:", err);
mutateVerge();
showNotice("error", err.toString());
// setOpen(true);
}
});
}); });
return ( return (
<BaseDialog <>
open={open} <Dialog open={open} onOpenChange={setOpen}>
title={t("System Proxy Setting")} <DialogContent className="sm:max-w-lg">
contentSx={{ width: 450, maxHeight: 565 }} <DialogHeader><DialogTitle>{t("System Proxy Setting")}</DialogTitle></DialogHeader>
okBtn={t("Save")} <div className="max-h-[70vh] overflow-y-auto space-y-4 py-4 px-1">
cancelBtn={t("Cancel")} <BaseFieldset label={t("Current System Proxy")}>
onClose={() => setOpen(false)} <div className="text-sm space-y-2">
onCancel={() => setOpen(false)} <div className="flex justify-between"><span className="text-muted-foreground">{t("Enable status")}</span><span>{value.pac ? (autoproxy?.enable ? t("Enabled") : t("Disabled")) : (sysproxy?.enable ? t("Enabled") : t("Disabled"))}</span></div>
onOk={onSave} {!value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("Server Addr")}</span><span className="font-mono">{getSystemProxyAddress}</span></div>}
loading={saving} {value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("PAC URL")}</span><span className="font-mono">{getCurrentPacUrl || "-"}</span></div>}
disableOk={saving} </div>
> </BaseFieldset>
<List>
<BaseFieldset label={t("Current System Proxy")} padding="15px 10px"> <SettingRow label={t("Proxy Host")}>
<FlexBox> <Combobox options={hostOptions} value={value.proxy_host} onValueChange={(val) => setValue(v => ({...v, proxy_host: val}))} placeholder="127.0.0.1" />
<Typography className="label">{t("Enable status")}</Typography> </SettingRow>
<Typography className="value"> <SettingRow label={t("Use PAC Mode")}>
{value.pac <Switch disabled={!enabled} checked={value.pac} onCheckedChange={(e) => setValue((v) => ({ ...v, pac: e }))} />
? autoproxy?.enable </SettingRow>
? t("Enabled") <SettingRow label={<>{t("Proxy Guard")} <TooltipIcon tooltip={t("Proxy Guard Info")} /></>}>
: t("Disabled") <Switch disabled={!enabled} checked={value.guard} onCheckedChange={(e) => setValue((v) => ({ ...v, guard: e }))} />
: sysproxy?.enable </SettingRow>
? t("Enabled") <SettingRow label={t("Guard Duration")}>
: t("Disabled")} <div className="flex items-center gap-2">
</Typography> <Input disabled={!enabled} type="number" className="w-24 h-8" value={value.duration} onChange={(e) => setValue((v) => ({ ...v, duration: +e.target.value.replace(/\D/, "") }))}/>
</FlexBox> <span className="text-sm text-muted-foreground">s</span>
{!value.pac && ( </div>
<> </SettingRow>
<FlexBox> {!value.pac && (
<Typography className="label">{t("Server Addr")}</Typography> <SettingRow label={t("Always use Default Bypass")}>
<Typography className="value"> <Switch disabled={!enabled} checked={value.use_default} onCheckedChange={(e) => setValue((v) => ({...v, use_default: e, bypass: !e && !v.bypass ? defaultBypass() : v.bypass}))}/>
{getSystemProxyAddress} </SettingRow>
</Typography>
</FlexBox>
</>
)}
{value.pac && (
<FlexBox>
<Typography className="label">{t("PAC URL")}</Typography>
<Typography className="value">
{getCurrentPacUrl || "-"}
</Typography>
</FlexBox>
)}
</BaseFieldset>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Host")} />
<Autocomplete
size="small"
sx={{ width: 150 }}
options={hostOptions}
value={value.proxy_host}
freeSolo
renderInput={(params) => (
<TextField {...params} placeholder="127.0.0.1" size="small" />
)} )}
onChange={(_, newValue) => { {!value.pac && !value.use_default && (
setValue((v) => ({ <div className="space-y-2">
...v, <Label>{t("Proxy Bypass")}</Label>
proxy_host: newValue || "127.0.0.1", <Textarea
})); id="proxy-bypass"
}} disabled={!enabled}
onInputChange={(_, newInputValue) => { rows={4}
setValue((v) => ({ value={value.bypass}
...v, onChange={(e) => setValue((v) => ({ ...v, bypass: e.target.value }))}
proxy_host: newInputValue || "127.0.0.1", // Вместо пропса `error` используем условные классы
})); className={cn(
}} (value.bypass && !validReg.test(value.bypass)) && "border-destructive focus-visible:ring-destructive"
/> )}
</ListItem> />
<ListItem sx={{ padding: "5px 2px" }}> </div>
<ListItemText primary={t("Use PAC Mode")} /> )}
<Switch {value.pac && (
edge="end" <SettingRow label={t("PAC Script Content")}>
disabled={!enabled} <Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}><Edit className="mr-2 h-4 w-4"/>{t("Edit")} PAC</Button>
checked={value.pac} </SettingRow>
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))} )}
/> </div>
</ListItem> <DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<ListItem sx={{ padding: "5px 2px" }}> <Button type="button" onClick={onSave} disabled={saving}>{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}{t("Save")}</Button>
<ListItemText </DialogFooter>
primary={t("Proxy Guard")} </DialogContent>
sx={{ maxWidth: "fit-content" }} </Dialog>
/> {editorOpen && <EditorViewer open={true} title={`${t("Edit")} PAC`} initialData={Promise.resolve(value.pac_content ?? "")} language="javascript" onSave={(_prev, curr) => { let pac = DEFAULT_PAC; if (curr && curr.trim().length > 0) { pac = curr; } setValue((v) => ({ ...v, pac_content: pac })); }} onClose={() => setEditorOpen(false)} />}
<TooltipIcon title={t("Proxy Guard Info")} sx={{ opacity: "0.7" }} /> </>
<Switch
edge="end"
disabled={!enabled}
checked={value.guard}
onChange={(_, e) => setValue((v) => ({ ...v, guard: e }))}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Guard Duration")} />
<TextField
disabled={!enabled}
size="small"
value={value.duration}
sx={{ width: 100 }}
slotProps={{
input: {
endAdornment: <InputAdornment position="end">s</InputAdornment>,
},
}}
onChange={(e) => {
setValue((v) => ({
...v,
duration: +e.target.value.replace(/\D/, ""),
}));
}}
/>
</ListItem>
{!value.pac && (
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Always use Default Bypass")} />
<Switch
edge="end"
disabled={!enabled}
checked={value.use_default}
onChange={(_, e) =>
setValue((v) => ({
...v,
use_default: e,
// 当取消选择use_default且当前bypass为空时填充默认值
bypass: !e && !v.bypass ? defaultBypass() : v.bypass,
}))
}
/>
</ListItem>
)}
{!value.pac && !value.use_default && (
<>
<ListItemText primary={t("Proxy Bypass")} />
<TextField
error={value.bypass ? !validReg.test(value.bypass) : false}
disabled={!enabled}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={value.bypass}
onChange={(e) => {
setValue((v) => ({ ...v, bypass: e.target.value }));
}}
/>
</>
)}
{!value.pac && value.use_default && (
<>
<ListItemText primary={t("Bypass")} />
<FlexBox>
<TextField
disabled={true}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={defaultBypass()}
/>
</FlexBox>
</>
)}
{value.pac && (
<>
<ListItem sx={{ padding: "5px 2px", alignItems: "start" }}>
<ListItemText
primary={t("PAC Script Content")}
sx={{ padding: "3px 0" }}
/>
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
}}
>
{t("Edit")} PAC
</Button>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} PAC`}
initialData={Promise.resolve(value.pac_content ?? "")}
language="javascript"
onSave={(_prev, curr) => {
let pac = DEFAULT_PAC;
if (curr && curr.trim().length > 0) {
pac = curr;
}
setValue((v) => ({ ...v, pac_content: pac }));
}}
onClose={() => setEditorOpen(false)}
/>
)}
</ListItem>
</>
)}
</List>
</BaseDialog>
); );
}); });
const FlexBox = styled("div")`
display: flex;
margin-top: 4px;
.label {
flex: none;
//width: 85px;
}
`;

View File

@@ -1,7 +1,8 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, ButtonGroup } from "@mui/material"; import { Button } from "@/components/ui/button";
type ThemeValue = IVergeConfig["theme_mode"]; // Определяем возможные значения темы для TypeScript
type ThemeValue = "light" | "dark" | "system";
interface Props { interface Props {
value?: ThemeValue; value?: ThemeValue;
@@ -12,20 +13,25 @@ export const ThemeModeSwitch = (props: Props) => {
const { value, onChange } = props; const { value, onChange } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const modes = ["light", "dark", "system"] as const; const modes: ThemeValue[] = ["light", "dark", "system"];
return ( return (
<ButtonGroup size="small" sx={{ my: "4px" }}> // Создаем ту же самую группу кнопок, что и раньше
<div className="flex items-center rounded-md border bg-muted p-0.5">
{modes.map((mode) => ( {modes.map((mode) => (
<Button <Button
key={mode} key={mode}
variant={mode === value ? "contained" : "outlined"} variant={mode === value ? "default" : "ghost"}
onClick={() => onChange?.(mode)} onClick={() => onChange?.(mode)}
sx={{ textTransform: "capitalize" }} size="sm"
className="capitalize px-3 text-xs"
> >
{/* Ключевое исправление: мы используем ключи `theme.light`, `theme.dark` и т.д.
Это стандартный подход в i18next для корректной локализации.
*/}
{t(`theme.${mode}`)} {t(`theme.${mode}`)}
</Button> </Button>
))} ))}
</ButtonGroup> </div>
); );
}; };

View File

@@ -1,25 +1,61 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState, useMemo } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useTheme } from "@mui/material/styles"; // Оставляем для получения дефолтных цветов темы
Button,
List, // Новые импорты
ListItem,
ListItemText,
styled,
TextField,
useTheme,
} from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { defaultTheme, defaultDarkTheme } from "@/pages/_theme"; import { defaultTheme, defaultDarkTheme } from "@/pages/_theme";
import { BaseDialog, DialogRef } from "@/components/base"; import { DialogRef } from "@/components/base";
import { EditorViewer } from "@/components/profile/editor-viewer"; import { EditorViewer } from "@/components/profile/editor-viewer";
import { EditRounded } from "@mui/icons-material";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Edit } from "lucide-react";
interface Props {}
// Дочерний компонент для одной строки настройки цвета
const ColorSettingRow = ({ label, value, placeholder, onChange }: {
label: string;
value: string;
placeholder: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) => (
<div className="flex items-center justify-between">
<Label>{label}</Label>
<div className="flex items-center gap-2">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Этот контейнер теперь позиционирован, чтобы спрятать input внутри */}
<div className="relative h-6 w-6 cursor-pointer">
{/* Видимый образец цвета */}
<div
className="h-full w-full rounded-full border"
style={{ backgroundColor: value || placeholder }}
/>
{/* Невидимый input, который и открывает палитру */}
<Input
type="color"
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
value={value || placeholder}
onChange={onChange}
/>
</div>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
<Input
className="w-32 h-8 font-mono text-sm"
value={value ?? ""}
placeholder={placeholder}
onChange={onChange}
/>
</div>
</div>
);
export const ThemeViewer = forwardRef<DialogRef>((props, ref) => { export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
@@ -34,12 +70,6 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false), close: () => setOpen(false),
})); }));
const textProps = {
size: "small",
autoComplete: "off",
sx: { width: 135 },
} as const;
const handleChange = (field: keyof typeof theme) => (e: any) => { const handleChange = (field: keyof typeof theme) => (e: any) => {
setTheme((t) => ({ ...t, [field]: e.target.value })); setTheme((t) => ({ ...t, [field]: e.target.value }));
}; };
@@ -48,111 +78,86 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
try { try {
await patchVerge({ theme_setting: theme }); await patchVerge({ theme_setting: theme });
setOpen(false); setOpen(false);
showNotice("success", t("Saved Successfully, please restart the app to take effect"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err.toString()); showNotice("error", err.toString());
} }
}); });
// default theme const muiTheme = useTheme();
const { palette } = useTheme(); const dt = muiTheme.palette.mode === "light" ? defaultTheme : defaultDarkTheme;
const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme;
type ThemeKey = keyof typeof theme & keyof typeof defaultTheme; type ThemeKey = keyof typeof theme & keyof typeof defaultTheme;
const renderItem = (label: string, key: ThemeKey) => { const renderItem = (label: string, key: ThemeKey) => {
return ( return (
<Item> <ColorSettingRow
<ListItemText primary={label} /> label={label}
<Round sx={{ background: theme[key] || dt[key] }} /> // --- НАЧАЛО ИСПРАВЛЕНИЯ ---
<TextField // Добавляем `?? ''` чтобы value всегда был строкой
{...textProps} value={theme[key] ?? ""}
value={theme[key] ?? ""} // --- КОНЕЦ ИСПРАВЛЕНИЯ ---
placeholder={dt[key]} placeholder={dt[key]}
onChange={handleChange(key)} onChange={handleChange(key)}
onKeyDown={(e) => e.key === "Enter" && onSave()} />
/>
</Item>
); );
}; };
return ( return (
<BaseDialog <>
open={open} <Dialog open={open} onOpenChange={setOpen}>
title={t("Theme Setting")} <DialogContent className="sm:max-w-md">
okBtn={t("Save")} <DialogHeader>
cancelBtn={t("Cancel")} <DialogTitle>{t("Theme Setting")}</DialogTitle>
contentSx={{ width: 400, maxHeight: 505, overflow: "auto", pb: 0 }} </DialogHeader>
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ pt: 0 }}>
{renderItem(t("Primary Color"), "primary_color")}
{renderItem(t("Secondary Color"), "secondary_color")} <div className="max-h-[70vh] overflow-y-auto space-y-3 p-1">
{renderItem(t("Primary Color"), "primary_color")}
{renderItem(t("Secondary Color"), "secondary_color")}
{renderItem(t("Primary Text"), "primary_text")}
{renderItem(t("Secondary Text"), "secondary_text")}
{renderItem(t("Info Color"), "info_color")}
{renderItem(t("Warning Color"), "warning_color")}
{renderItem(t("Error Color"), "error_color")}
{renderItem(t("Success Color"), "success_color")}
{renderItem(t("Primary Text"), "primary_text")} <div className="flex items-center justify-between py-2">
<Label>{t("Font Family")}</Label>
<Input
className="w-48 h-8"
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
value={theme.font_family ?? ""}
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
onChange={handleChange("font_family")}
/>
</div>
{renderItem(t("Secondary Text"), "secondary_text")} <div className="flex items-center justify-between py-2">
<Label>{t("CSS Injection")}</Label>
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}>
<Edit className="mr-2 h-4 w-4" />{t("Edit")} CSS
</Button>
</div>
</div>
{renderItem(t("Info Color"), "info_color")} <DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{renderItem(t("Warning Color"), "warning_color")} {editorOpen && (
<EditorViewer
{renderItem(t("Error Color"), "error_color")} open={true}
title={`${t("Edit")} CSS`}
{renderItem(t("Success Color"), "success_color")} initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
<Item> onSave={(_prev, curr) => {
<ListItemText primary={t("Font Family")} /> setTheme(v => ({ ...v, css_injection: curr }));
<TextField }}
{...textProps} onClose={() => setEditorOpen(false)}
value={theme.font_family ?? ""} />
onChange={handleChange("font_family")} )}
onKeyDown={(e) => e.key === "Enter" && onSave()} </>
/>
</Item>
<Item>
<ListItemText primary={t("CSS Injection")} />
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
}}
>
{t("Edit")} CSS
</Button>
{editorOpen && (
<EditorViewer
open={true}
title={`${t("Edit")} CSS`}
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
theme.css_injection = curr;
handleChange("css_injection");
}}
onClose={() => {
setEditorOpen(false);
}}
/>
)}
</Item>
</List>
</BaseDialog>
); );
}); });
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));
const Round = styled("div")(() => ({
width: "24px",
height: "24px",
borderRadius: "18px",
display: "inline-block",
marginRight: "8px",
}));

View File

@@ -1,32 +1,51 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useLockFn, useRequest } from "ahooks";
List, import { mutate } from "swr";
ListItem, import { useClash, useClashInfo } from "@/hooks/use-clash";
ListItemText, import { useVerge } from "@/hooks/use-verge";
Box, import { enhanceProfiles, restartCore } from "@/services/cmds";
Typography,
Button,
TextField,
} from "@mui/material";
import { useClash } from "@/hooks/use-clash";
import { BaseDialog, DialogRef, Switch } from "@/components/base";
import { StackModeSwitch } from "./stack-mode-switch";
import { enhanceProfiles } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
// Новые импорты
import { DialogRef, Switch } from "@/components/base";
import { StackModeSwitch } from "./stack-mode-switch";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RotateCcw, Layers, Laptop, Route, RouteOff, Network, Dna, Gauge } from "lucide-react";
const OS = getSystem(); const OS = getSystem();
type StackMode = "mixed" | "gvisor" | "system";
// Компоненты-хелперы
const SettingRow = ({ label, children }: { label: React.ReactNode; children?: React.ReactNode; }) => (
<div className="flex items-center justify-between py-3 border-b border-border last:border-b-0">
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div></div>
<div>{children}</div>
</div>
);
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
};
export const TunViewer = forwardRef<DialogRef>((props, ref) => { export const TunViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clash, mutateClash, patchClash } = useClash(); const { clash, mutateClash, patchClash } = useClash();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [values, setValues] = useState({ const [values, setValues] = useState({
stack: "mixed", stack: "gvisor" as StackMode,
device: OS === "macos" ? "utun1024" : "Mihomo", device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true, autoRoute: true,
autoDetectInterface: true, autoDetectInterface: true,
@@ -39,7 +58,10 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
open: () => { open: () => {
setOpen(true); setOpen(true);
setValues({ setValues({
stack: clash?.tun.stack ?? "gvisor", // --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Добавляем утверждение типа, чтобы TypeScript был уверен в значении
stack: (clash?.tun.stack as StackMode) ?? "gvisor",
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"), device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"),
autoRoute: clash?.tun["auto-route"] ?? true, autoRoute: clash?.tun["auto-route"] ?? true,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true, autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
@@ -51,16 +73,23 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false), close: () => setOpen(false),
})); }));
const resetToDefaults = () => {
setValues({
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
dnsHijack: ["any:53"],
strictRoute: false,
mtu: 1500,
});
};
const onSave = useLockFn(async () => { const onSave = useLockFn(async () => {
try { try {
let tun = { const tun = {
stack: values.stack, stack: values.stack,
device: device: values.device === "" ? (OS === "macos" ? "utun1024" : "Mihomo") : values.device,
values.device === ""
? OS === "macos"
? "utun1024"
: "Mihomo"
: values.device,
"auto-route": values.autoRoute, "auto-route": values.autoRoute,
"auto-detect-interface": values.autoDetectInterface, "auto-detect-interface": values.autoDetectInterface,
"dns-hijack": values.dnsHijack[0] === "" ? [] : values.dnsHijack, "dns-hijack": values.dnsHijack[0] === "" ? [] : values.dnsHijack,
@@ -68,13 +97,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
mtu: values.mtu ?? 1500, mtu: values.mtu ?? 1500,
}; };
await patchClash({ tun }); await patchClash({ tun });
await mutateClash( await mutateClash((old) => ({ ...(old! || {}), tun }), false);
(old) => ({
...(old! || {}),
tun,
}),
false,
);
try { try {
await enhanceProfiles(); await enhanceProfiles();
showNotice("success", t("Settings Applied")); showNotice("success", t("Settings Applied"));
@@ -88,152 +111,50 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
}); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={ <DialogHeader>
<Box display="flex" justifyContent="space-between" gap={1}> <div className="flex justify-between items-center pr-12">
<Typography variant="h6">{t("Tun Mode")}</Typography> <DialogTitle>{t("Tun Mode")}</DialogTitle>
<Button <Button variant="outline" size="sm" onClick={resetToDefaults}>
variant="outlined" <RotateCcw className="mr-2 h-4 w-4" />
size="small" {t("Reset to Default")}
onClick={async () => { </Button>
let tun = { </div>
stack: "gvisor", </DialogHeader>
device: OS === "macos" ? "utun1024" : "Mihomo",
"auto-route": true,
"auto-detect-interface": true,
"dns-hijack": ["any:53"],
"strict-route": false,
mtu: 1500,
};
setValues({
stack: "gvisor",
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
dnsHijack: ["any:53"],
strictRoute: false,
mtu: 1500,
});
await patchClash({ tun });
await mutateClash(
(old) => ({
...(old! || {}),
tun,
}),
false,
);
}}
>
{t("Reset to Default")}
</Button>
</Box>
}
contentSx={{ width: 450 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Stack")} />
<StackModeSwitch
value={values.stack}
onChange={(value) => {
setValues((v) => ({
...v,
stack: value,
}));
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}> <div className="max-h-[70vh] overflow-y-auto space-y-1 px-1">
<ListItemText primary={t("Device")} /> <SettingRow label={<LabelWithIcon icon={Layers} text={t("Stack")} />}>
<TextField <StackModeSwitch
autoComplete="new-password" value={values.stack}
size="small" onChange={(value) => setValues((v) => ({ ...v, stack: value }))}
autoCorrect="off" />
autoCapitalize="off" </SettingRow>
spellCheck="false" <SettingRow label={<LabelWithIcon icon={Laptop} text={t("Device")} />}>
sx={{ width: 250 }} <Input className="h-8 w-40" value={values.device} placeholder="Mihomo" onChange={(e) => setValues((v) => ({ ...v, device: e.target.value }))} />
value={values.device} </SettingRow>
placeholder="Mihomo" <SettingRow label={<LabelWithIcon icon={Route} text={t("Auto Route")} />}>
onChange={(e) => <Switch checked={values.autoRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, autoRoute: c }))} />
setValues((v) => ({ ...v, device: e.target.value })) </SettingRow>
} <SettingRow label={<LabelWithIcon icon={RouteOff} text={t("Strict Route")} />}>
/> <Switch checked={values.strictRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, strictRoute: c }))} />
</ListItem> </SettingRow>
<SettingRow label={<LabelWithIcon icon={Network} text={t("Auto Detect Interface")} />}>
<Switch checked={values.autoDetectInterface} onCheckedChange={(c) => setValues((v) => ({ ...v, autoDetectInterface: c }))} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Dna} text={t("DNS Hijack")} />}>
<Input className="h-8 w-40" value={values.dnsHijack.join(",")} placeholder="any:53" onChange={(e) => setValues((v) => ({ ...v, dnsHijack: e.target.value.split(",") }))} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Gauge} text={t("MTU")} />}>
<Input type="number" className="h-8 w-40" value={values.mtu} placeholder="1500" onChange={(e) => setValues((v) => ({ ...v, mtu: parseInt(e.target.value, 10) || 0 }))} />
</SettingRow>
</div>
<ListItem sx={{ padding: "5px 2px" }}> <DialogFooter>
<ListItemText primary={t("Auto Route")} /> <DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Switch <Button type="button" onClick={onSave}>{t("Save")}</Button>
edge="end" </DialogFooter>
checked={values.autoRoute} </DialogContent>
onChange={(_, c) => setValues((v) => ({ ...v, autoRoute: c }))} </Dialog>
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Strict Route")} />
<Switch
edge="end"
checked={values.strictRoute}
onChange={(_, c) => setValues((v) => ({ ...v, strictRoute: c }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Detect Interface")} />
<Switch
edge="end"
checked={values.autoDetectInterface}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoDetectInterface: c }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("DNS Hijack")} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.dnsHijack.join(",")}
placeholder="Please use , to separate multiple DNS servers"
onChange={(e) =>
setValues((v) => ({ ...v, dnsHijack: e.target.value.split(",") }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("MTU")} />
<TextField
autoComplete="new-password"
size="small"
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.mtu}
placeholder="1500"
onChange={(e) =>
setValues((v) => ({
...v,
mtu: parseInt(e.target.value),
}))
}
/>
</ListItem>
</List>
</BaseDialog>
); );
}); });

View File

@@ -1,31 +1,31 @@
import useSWR from "swr"; import useSWR from "swr";
import { import { forwardRef, useImperativeHandle, useState, useMemo, useEffect } from "react";
forwardRef,
useImperativeHandle,
useState,
useMemo,
useEffect,
} from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { Box, LinearProgress, Button } from "@mui/material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { relaunch } from "@tauri-apps/plugin-process"; import { relaunch } from "@tauri-apps/plugin-process";
import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { BaseDialog, DialogRef } from "@/components/base";
import { useUpdateState, useSetUpdateState } from "@/services/states";
import { Event, UnlistenFn } from "@tauri-apps/api/event"; import { Event, UnlistenFn } from "@tauri-apps/api/event";
import { portableFlag } from "@/pages/_layout";
import { open as openUrl } from "@tauri-apps/plugin-shell"; import { open as openUrl } from "@tauri-apps/plugin-shell";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
// Новые импорты
import { DialogRef } from "@/components/base";
import { useUpdateState, useSetUpdateState } from "@/services/states";
import { portableFlag } from "@/pages/_layout";
import { useListen } from "@/hooks/use-listen"; import { useListen } from "@/hooks/use-listen";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertTriangle, ExternalLink } from "lucide-react";
export const UpdateViewer = forwardRef<DialogRef>((props, ref) => { export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [currentProgressListener, setCurrentProgressListener] = const [currentProgressListener, setCurrentProgressListener] = useState<UnlistenFn | null>(null);
useState<UnlistenFn | null>(null);
const updateState = useUpdateState(); const updateState = useUpdateState();
const setUpdateState = useSetUpdateState(); const setUpdateState = useSetUpdateState();
@@ -34,11 +34,10 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, { const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2, errorRetryCount: 2,
revalidateIfStale: false, revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour focusThrottleInterval: 36e5,
}); });
const [downloaded, setDownloaded] = useState(0); const [downloaded, setDownloaded] = useState(0);
const [buffer, setBuffer] = useState(0);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -47,44 +46,29 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
})); }));
const markdownContent = useMemo(() => { const markdownContent = useMemo(() => {
if (!updateInfo?.body) { if (!updateInfo?.body) return t("New Version is available");
return "New Version is available"; return updateInfo.body;
} }, [updateInfo, t]);
return updateInfo?.body;
}, [updateInfo]);
const breakChangeFlag = useMemo(() => { const breakChangeFlag = useMemo(() => {
if (!updateInfo?.body) { return updateInfo?.body?.toLowerCase().includes("break change") ?? false;
return false;
}
return updateInfo?.body.toLowerCase().includes("break change");
}, [updateInfo]); }, [updateInfo]);
const onUpdate = useLockFn(async () => { const onUpdate = useLockFn(async () => {
if (portableFlag) { if (portableFlag) { showNotice("error", t("Portable Updater Error")); return; }
showNotice("error", t("Portable Updater Error"));
return;
}
if (!updateInfo?.body) return; if (!updateInfo?.body) return;
if (breakChangeFlag) { if (breakChangeFlag) { showNotice("error", t("Break Change Update Error")); return; }
showNotice("error", t("Break Change Update Error"));
return;
}
if (updateState) return; if (updateState) return;
setUpdateState(true); setUpdateState(true);
setDownloaded(0); // Сбрасываем прогресс перед новой загрузкой
setTotal(0);
if (currentProgressListener) { if (currentProgressListener) currentProgressListener();
currentProgressListener();
}
const progressListener = await addListener( const progressListener = await addListener("tauri://update-download-progress", (e: Event<any>) => {
"tauri://update-download-progress",
(e: Event<any>) => {
setTotal(e.payload.contentLength); setTotal(e.payload.contentLength);
setBuffer(e.payload.chunkLength); setDownloaded((prev) => prev + e.payload.chunkLength);
setDownloaded((a) => {
return a + e.payload.chunkLength;
});
}, },
); );
setCurrentProgressListener(() => progressListener); setCurrentProgressListener(() => progressListener);
@@ -96,74 +80,66 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
showNotice("error", err?.message || err.toString()); showNotice("error", err?.message || err.toString());
} finally { } finally {
setUpdateState(false); setUpdateState(false);
if (progressListener) { progressListener?.();
progressListener();
}
setCurrentProgressListener(null); setCurrentProgressListener(null);
} }
}); });
useEffect(() => { useEffect(() => {
return () => { return () => { currentProgressListener?.(); };
if (currentProgressListener) {
console.log("UpdateViewer unmounting, cleaning up progress listener.");
currentProgressListener();
}
};
}, [currentProgressListener]); }, [currentProgressListener]);
const downloadProgress = total > 0 ? (downloaded / total) * 100 : 0;
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-2xl">
title={ <DialogHeader>
<Box display="flex" justifyContent="space-between"> <div className="flex justify-between items-center">
{`New Version v${updateInfo?.version}`} <DialogTitle>{t("New Version")} v{updateInfo?.version}</DialogTitle>
<Box>
<Button <Button
variant="contained" variant="outline"
size="small" size="sm"
onClick={() => { onClick={() => openUrl(`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`)}
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
);
}}
> >
<ExternalLink className="mr-2 h-4 w-4" />
{t("Go to Release Page")} {t("Go to Release Page")}
</Button> </Button>
</Box> </div>
</Box> </DialogHeader>
}
contentSx={{ minWidth: 360, maxWidth: 400, height: "50vh" }} <div className="max-h-[60vh] overflow-y-auto my-4 pr-6 -mr-6">
okBtn={t("Update")} {breakChangeFlag && (
cancelBtn={t("Cancel")} <Alert variant="destructive" className="mb-4">
onClose={() => setOpen(false)} <AlertTriangle className="h-4 w-4" />
onCancel={() => setOpen(false)} <AlertTitle>{t("Warning")}</AlertTitle>
onOk={onUpdate} <AlertDescription>{t("Break Change Warning")}</AlertDescription>
> </Alert>
<Box sx={{ height: "calc(100% - 10px)", overflow: "auto" }}> )}
<ReactMarkdown {/* Оборачиваем ReactMarkdown для красивой стилизации */}
components={{ <article className="prose prose-sm dark:prose-invert max-w-none">
a: ({ node, ...props }) => { <ReactMarkdown
const { children } = props; components={{ a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
return ( >
<a {...props} target="_blank"> {markdownContent}
{children} </ReactMarkdown>
</a> </article>
); </div>
},
}} {updateState && (
> <div className="w-full space-y-1">
{markdownContent} <Progress value={downloadProgress} />
</ReactMarkdown> <p className="text-xs text-muted-foreground text-right">{Math.round(downloadProgress)}%</p>
</Box> </div>
{updateState && ( )}
<LinearProgress
variant="buffer" <DialogFooter>
value={(downloaded / total) * 100} <DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
valueBuffer={buffer} <Button type="button" onClick={onUpdate} disabled={updateState || breakChangeFlag}>
sx={{ marginTop: "5px" }} {t("Update")}
/> </Button>
)} </DialogFooter>
</BaseDialog> </DialogContent>
</Dialog>
); );
}); });

View File

@@ -1,20 +1,14 @@
import { useState } from "react"; import React, { useState } from "react";
import {
Divider,
IconButton,
Stack,
TextField,
Typography,
} from "@mui/material";
import {
CheckRounded,
CloseRounded,
DeleteRounded,
EditRounded,
OpenInNewRounded,
} from "@mui/icons-material";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Check, X, Trash2, Edit3, ExternalLink } from "lucide-react";
interface Props { interface Props {
value?: string; value?: string;
onlyEdit?: boolean; onlyEdit?: boolean;
@@ -24,6 +18,25 @@ interface Props {
onCancel?: () => void; onCancel?: () => void;
} }
// Новая функция для безопасного рендеринга URL с подсветкой
const HighlightedUrl = ({ url }: { url: string }) => {
// Разбиваем строку по плейсхолдерам, сохраняя их в результате
const parts = url.split(/(%host%|%port%|%secret%)/g);
return (
<p className="truncate text-sm" title={url}>
{parts.map((part, index) =>
part.startsWith('%') && part.endsWith('%') ? (
<span key={index} className="font-semibold text-primary">{part}</span>
) : (
<span key={index}>{part}</span>
)
)}
</p>
);
};
export const WebUIItem = (props: Props) => { export const WebUIItem = (props: Props) => {
const { const {
value, value,
@@ -38,97 +51,83 @@ export const WebUIItem = (props: Props) => {
const [editValue, setEditValue] = useState(value); const [editValue, setEditValue] = useState(value);
const { t } = useTranslation(); const { t } = useTranslation();
const handleSave = () => {
onChange(editValue);
setEditing(false);
};
const handleCancel = () => {
onCancel?.();
setEditing(false);
};
// --- Рендер режима редактирования ---
if (editing || onlyEdit) { if (editing || onlyEdit) {
return ( return (
<> <div className="w-full">
<Stack spacing={0.75} direction="row" mt={1} mb={1} alignItems="center"> <div className="flex items-center gap-2 mt-1 mb-1">
<TextField <Input
autoComplete="new-password" autoFocus
fullWidth placeholder={t("Support %host, %port, %secret")}
size="small"
value={editValue} value={editValue}
onChange={(e) => setEditValue(e.target.value)} onChange={(e) => setEditValue(e.target.value)}
placeholder={t("Support %host, %port, %secret")} onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') handleCancel(); }}
/> />
<IconButton <TooltipProvider>
size="small" <Tooltip>
title={t("Save")} <TooltipTrigger asChild>
color="inherit" <Button size="icon" onClick={handleSave}><Check className="h-4 w-4" /></Button>
onClick={() => { </TooltipTrigger>
onChange(editValue); <TooltipContent><p>{t("Save")}</p></TooltipContent>
setEditing(false); </Tooltip>
}} <Tooltip>
> <TooltipTrigger asChild>
<CheckRounded fontSize="inherit" /> <Button size="icon" variant="ghost" onClick={handleCancel}><X className="h-4 w-4" /></Button>
</IconButton> </TooltipTrigger>
<IconButton <TooltipContent><p>{t("Cancel")}</p></TooltipContent>
size="small" </Tooltip>
title={t("Cancel")} </TooltipProvider>
color="inherit" </div>
onClick={() => { {!onlyEdit && <Separator />}
onCancel?.(); </div>
setEditing(false);
}}
>
<CloseRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
); );
} }
const html = value // --- Рендер режима просмотра ---
?.replace("%host", "<span>%host</span>")
.replace("%port", "<span>%port</span>")
.replace("%secret", "<span>%secret</span>");
return ( return (
<> <div className="w-full">
<Stack spacing={0.75} direction="row" alignItems="center" mt={1} mb={1}> <div className="flex items-center gap-2 mt-1 mb-1 h-10"> {/* h-10 для сохранения высоты */}
<Typography <div className="flex-1 min-w-0">
component="div" {value ? <HighlightedUrl url={value} /> : <p className="text-sm text-muted-foreground">NULL</p>}
width="100%" </div>
title={value} <TooltipProvider>
color={value ? "text.primary" : "text.secondary"} <Tooltip>
sx={({ palette }) => ({ <TooltipTrigger asChild>
overflow: "hidden", <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => onOpenUrl?.(value)}>
textOverflow: "ellipsis", <ExternalLink className="h-4 w-4" />
"> span": { </Button>
color: palette.primary.main, </TooltipTrigger>
}, <TooltipContent><p>{t("Open URL")}</p></TooltipContent>
})} </Tooltip>
dangerouslySetInnerHTML={{ __html: html || "NULL" }} <Tooltip>
/> <TooltipTrigger asChild>
<IconButton <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditing(true); setEditValue(value); }}>
size="small" <Edit3 className="h-4 w-4" />
title={t("Open URL")} </Button>
color="inherit" </TooltipTrigger>
onClick={() => onOpenUrl?.(value)} <TooltipContent><p>{t("Edit")}</p></TooltipContent>
> </Tooltip>
<OpenInNewRounded fontSize="inherit" /> <Tooltip>
</IconButton> <TooltipTrigger asChild>
<IconButton <Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={onDelete}>
size="small" <Trash2 className="h-4 w-4" />
title={t("Edit")} </Button>
color="inherit" </TooltipTrigger>
onClick={() => { <TooltipContent><p>{t("Delete")}</p></TooltipContent>
setEditing(true); </Tooltip>
setEditValue(value); </TooltipProvider>
}} </div>
> <Separator />
<EditRounded fontSize="inherit" /> </div>
</IconButton>
<IconButton
size="small"
title={t("Delete")}
color="inherit"
onClick={onDelete}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Stack>
<Divider />
</>
); );
}; };

View File

@@ -1,17 +1,28 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button, Box, Typography } from "@mui/material"; import { useLockFn } from "ahooks";
import { useVerge } from "@/hooks/use-verge";
import { openWebUrl } from "@/services/cmds"; import { openWebUrl } from "@/services/cmds";
import { BaseDialog, BaseEmpty, DialogRef } from "@/components/base"; import { useVerge } from "@/hooks/use-verge";
import { useClashInfo } from "@/hooks/use-clash"; import { useClashInfo } from "@/hooks/use-clash";
import { WebUIItem } from "./web-ui-item";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// Новые импорты
import { DialogRef, BaseEmpty } from "@/components/base";
import { WebUIItem } from "./web-ui-item"; // Наш обновленный компонент
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Plus } from "lucide-react";
export const WebUIViewer = forwardRef<DialogRef>((props, ref) => { export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { clashInfo } = useClashInfo(); const { clashInfo } = useClashInfo();
const { verge, patchVerge, mutateVerge } = useVerge(); const { verge, patchVerge, mutateVerge } = useVerge();
@@ -29,6 +40,7 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
"https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret", "https://board.zash.run.place/#/setup?http=true&hostname=%host&port=%port&secret=%secret",
]; ];
// Вся ваша логика остается без изменений
const handleAdd = useLockFn(async (value: string) => { const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value]; const newList = [...webUIList, value];
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false); mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false);
@@ -59,18 +71,10 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
if (!clashInfo.server?.includes(":")) { if (!clashInfo.server?.includes(":")) {
throw new Error(`failed to parse the server "${clashInfo.server}"`); throw new Error(`failed to parse the server "${clashInfo.server}"`);
} }
const port = clashInfo.server.slice(clashInfo.server.indexOf(":") + 1).trim();
const port = clashInfo.server
.slice(clashInfo.server.indexOf(":") + 1)
.trim();
url = url.replaceAll("%port", port || "9097"); url = url.replaceAll("%port", port || "9097");
url = url.replaceAll( url = url.replaceAll("%secret", encodeURIComponent(clashInfo.secret || ""));
"%secret",
encodeURIComponent(clashInfo.secret || ""),
);
} }
await openWebUrl(url); await openWebUrl(url);
} catch (e: any) { } catch (e: any) {
showNotice("error", e.message || e.toString()); showNotice("error", e.message || e.toString());
@@ -78,63 +82,59 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
}); });
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-xl">
title={ <DialogHeader className="pr-7">
<Box display="flex" justifyContent="space-between"> <div className="flex justify-between items-center">
{t("Web UI")} <DialogTitle>{t("Web UI")}</DialogTitle>
<Button <Button size="sm" disabled={editing} onClick={() => setEditing(true)}>
variant="contained" <Plus className="mr-2 h-4 w-4" />
size="small" {t("New")}
disabled={editing} </Button>
onClick={() => setEditing(true)} </div>
> </DialogHeader>
{t("New")}
</Button>
</Box>
}
contentSx={{
width: 450,
height: 300,
pb: 1,
overflowY: "auto",
userSelect: "text",
}}
cancelBtn={t("Close")}
disableOk
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
{!editing && webUIList.length === 0 && (
<BaseEmpty
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
{t("Replace host, port, secret with %host, %port, %secret")}
</Typography>
}
/>
)}
{webUIList.map((item, index) => ( <div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
<WebUIItem {!editing && webUIList.length === 0 ? (
key={index} <div className="h-40"> {/* Задаем высоту для центрирования */}
value={item} <BaseEmpty
onChange={(v) => handleChange(index, v)} extra={
onDelete={() => handleDelete(index)} <p className="mt-2 text-xs text-center">
onOpenUrl={handleOpenUrl} {t("Replace host, port, secret with %host, %port, %secret")}
/> </p>
))} }
{editing && ( />
<WebUIItem </div>
value="" ) : (
onlyEdit webUIList.map((item, index) => (
onChange={(v) => { <WebUIItem
setEditing(false); key={index}
handleAdd(v || ""); value={item}
}} onChange={(v) => handleChange(index, v)}
onCancel={() => setEditing(false)} onDelete={() => handleDelete(index)}
/> onOpenUrl={handleOpenUrl}
)} />
</BaseDialog> ))
)}
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
if (v) handleAdd(v);
}}
onCancel={() => setEditing(false)}
/>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

View File

@@ -1,25 +1,43 @@
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { useClash } from "@/hooks/use-clash";
import { useListen } from "@/hooks/use-listen";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData } from "@/services/api";
import { invoke_uwp_tool } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { LanRounded, SettingsRounded } from "@mui/icons-material";
import { MenuItem, Select, TextField, Typography } from "@mui/material";
import { invoke } from "@tauri-apps/api/core";
import { useLockFn } from "ahooks";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR, { mutate } from "swr";
import { useLockFn } from "ahooks";
import { invoke } from "@tauri-apps/api/core";
import getSystem from "@/utils/get-system";
// Сервисы и хуки
import { useClash } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { updateGeoData, closeAllConnections } from "@/services/api";
import { showNotice } from "@/services/noticeService";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
import { getRunningMode, invoke_uwp_tool } from "@/services/cmds";
// Компоненты
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { GuardState } from "./mods/guard-state";
// Иконки
import {
Settings, Network, Dna, Globe2, Timer, FileText, Plug, RadioTower,
LayoutDashboard, Cog, Repeat, Map as MapIcon
} from "lucide-react";
// Модальные окна
import { ClashCoreViewer } from "./mods/clash-core-viewer"; import { ClashCoreViewer } from "./mods/clash-core-viewer";
import { ClashPortViewer } from "./mods/clash-port-viewer"; import { ClashPortViewer } from "./mods/clash-port-viewer";
import { ControllerViewer } from "./mods/controller-viewer"; import { ControllerViewer } from "./mods/controller-viewer";
import { DnsViewer } from "./mods/dns-viewer"; import { DnsViewer } from "./mods/dns-viewer";
import { GuardState } from "./mods/guard-state";
import { NetworkInterfaceViewer } from "./mods/network-interface-viewer"; import { NetworkInterfaceViewer } from "./mods/network-interface-viewer";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { WebUIViewer } from "./mods/web-ui-viewer"; import { WebUIViewer } from "./mods/web-ui-viewer";
const isWIN = getSystem() === "windows"; const isWIN = getSystem() === "windows";
@@ -28,6 +46,20 @@ interface Props {
onError: (err: Error) => void; onError: (err: Error) => void;
} }
// Компонент для строки настроек
const SettingRow = ({ label, extra, children, onClick }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; }) => (
<div className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`} onClick={onClick}>
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
</div>
);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
};
const SettingClash = ({ onError }: Props) => { const SettingClash = ({ onError }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,18 +71,14 @@ const SettingClash = ({ onError }: Props) => {
"allow-lan": allowLan, "allow-lan": allowLan,
"log-level": logLevel, "log-level": logLevel,
"unified-delay": unifiedDelay, "unified-delay": unifiedDelay,
dns,
} = clash ?? {}; } = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {}; const { verge_mixed_port } = verge ?? {};
// 独立跟踪DNS设置开关状态
const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => { const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => {
return verge?.enable_dns_settings ?? false; return verge?.enable_dns_settings ?? false;
}); });
const { addListener } = useListen();
const webRef = useRef<DialogRef>(null); const webRef = useRef<DialogRef>(null);
const portRef = useRef<DialogRef>(null); const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null); const ctrlRef = useRef<DialogRef>(null);
@@ -58,23 +86,22 @@ const SettingClash = ({ onError }: Props) => {
const networkRef = useRef<DialogRef>(null); const networkRef = useRef<DialogRef>(null);
const dnsRef = useRef<DialogRef>(null); const dnsRef = useRef<DialogRef>(null);
const onSwitchFormat = (_e: any, value: boolean) => value; const onSwitchFormat = (value: boolean) => value;
const onSelectFormat = (value: string) => value;
const onChangeData = (patch: Partial<IConfigData>) => { const onChangeData = (patch: Partial<IConfigData>) => {
mutateClash((old) => ({ ...(old! || {}), ...patch }), false); mutateClash((old) => ({ ...(old! || {}), ...patch }), false);
}; };
const onChangeVerge = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false); const onUpdateGeo = useLockFn(async () => {
};
const onUpdateGeo = async () => {
try { try {
await updateGeoData(); await updateGeoData();
showNotice("success", t("GeoData Updated")); showNotice("success", t("GeoData Updated"));
} catch (err: any) { } catch (err: any) {
showNotice("error", err?.response.data.message || err.toString()); showNotice("error", err?.response?.data?.message || err.toString());
} }
}; });
// 实现DNS设置开关处理函数
const handleDnsToggle = useLockFn(async (enable: boolean) => { const handleDnsToggle = useLockFn(async (enable: boolean) => {
try { try {
setDnsSettingsEnabled(enable); setDnsSettingsEnabled(enable);
@@ -94,170 +121,70 @@ const SettingClash = ({ onError }: Props) => {
}); });
return ( return (
<SettingList title={t("Clash Setting")}> <div>
<WebUIViewer ref={webRef} /> <h3 className="text-lg font-medium mb-4">{t("Clash Setting")}</h3>
<ClashPortViewer ref={portRef} /> <div className="space-y-1">
<ControllerViewer ref={ctrlRef} /> <WebUIViewer ref={webRef} />
<ClashCoreViewer ref={coreRef} /> <ClashPortViewer ref={portRef} />
<NetworkInterfaceViewer ref={networkRef} /> <ControllerViewer ref={ctrlRef} />
<DnsViewer ref={dnsRef} /> <ClashCoreViewer ref={coreRef} />
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<SettingItem <SettingRow label={<LabelWithIcon icon={Network} text={t("Allow Lan")} />} extra={<TooltipIcon tooltip={t("Network Interface")} icon={<Settings className="h-4 w-4"/>} onClick={() => networkRef.current?.open()} />}>
label={t("Allow Lan")} <GuardState value={allowLan ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "allow-lan": e })} onGuard={(e) => patchClash({ "allow-lan": e })} onCatch={onError}>
extra={ <Switch />
<TooltipIcon </GuardState>
title={t("Network Interface")} </SettingRow>
color={"inherit"}
icon={LanRounded}
onClick={() => {
networkRef.current?.open();
}}
/>
}
>
<GuardState
value={allowLan ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "allow-lan": e })}
onGuard={(e) => patchClash({ "allow-lan": e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem <SettingRow label={<LabelWithIcon icon={Dna} text={t("DNS Overwrite")} />} extra={<TooltipIcon tooltip={t("DNS Settings")} icon={<Settings className="h-4 w-4"/>} onClick={() => dnsRef.current?.open()} />}>
label={t("DNS Overwrite")} <Switch checked={dnsSettingsEnabled} onCheckedChange={handleDnsToggle} />
extra={ </SettingRow>
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
}
>
<Switch
edge="end"
checked={dnsSettingsEnabled}
onChange={(_, checked) => handleDnsToggle(checked)}
/>
</SettingItem>
<SettingItem label={t("IPv6")}> <SettingRow label={<LabelWithIcon icon={Globe2} text={t("IPv6")} />}>
<GuardState <GuardState value={ipv6 ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ ipv6: e })} onGuard={(e) => patchClash({ ipv6: e })} onCatch={onError}>
value={ipv6 ?? false} <Switch />
valueProps="checked" </GuardState>
onCatch={onError} </SettingRow>
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ ipv6: e })}
onGuard={(e) => patchClash({ ipv6: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem <SettingRow label={<LabelWithIcon icon={Timer} text={t("Unified Delay")} />} extra={<TooltipIcon tooltip={t("Unified Delay Info")} />}>
label={t("Unified Delay")} <GuardState value={unifiedDelay ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "unified-delay": e })} onGuard={(e) => patchClash({ "unified-delay": e })} onCatch={onError}>
extra={ <Switch />
<TooltipIcon </GuardState>
title={t("Unified Delay Info")} </SettingRow>
sx={{ opacity: "0.7" }}
/>
}
>
<GuardState
value={unifiedDelay ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ "unified-delay": e })}
onGuard={(e) => patchClash({ "unified-delay": e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem <SettingRow label={<LabelWithIcon icon={FileText} text={t("Log Level")} />} extra={<TooltipIcon tooltip={t("Log Level Info")} />}>
label={t("Log Level")} <GuardState value={logLevel ?? "info"} valueProps="value" onChangeProps="onValueChange" onFormat={onSelectFormat} onChange={(e) => onChangeData({ "log-level": e })} onGuard={(e) => patchClash({ "log-level": e })} onCatch={onError}>
extra={ <Select value={logLevel}>
<TooltipIcon title={t("Log Level Info")} sx={{ opacity: "0.7" }} /> <SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
} <SelectContent>
> <SelectItem value="info">Info</SelectItem>
<GuardState <SelectItem value="warning">Warning</SelectItem>
value={logLevel === "warn" ? "warning" : (logLevel ?? "info")} <SelectItem value="error">Error</SelectItem>
onCatch={onError} <SelectItem value="silent">Silent</SelectItem>
onFormat={(e: any) => e.target.value} <SelectItem value="debug">Debug</SelectItem>
onChange={(e) => onChangeData({ "log-level": e })} </SelectContent>
onGuard={(e) => patchClash({ "log-level": e })} </Select>
> </GuardState>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}> </SettingRow>
<MenuItem value="debug">Debug</MenuItem>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warning">Warn</MenuItem>
<MenuItem value="error">Error</MenuItem>
<MenuItem value="silent">Silent</MenuItem>
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Port Config")}> <SettingRow label={<LabelWithIcon icon={Plug} text={t("Port Config")} />}>
<TextField <Button variant="outline" className="w-28 h-8 font-mono" onClick={() => portRef.current?.open()}>{verge_mixed_port ?? 7897}</Button>
autoComplete="new-password" </SettingRow>
disabled={false}
size="small"
value={verge_mixed_port ?? 7897}
sx={{ width: 100, input: { py: "7.5px", cursor: "pointer" } }}
onClick={(e) => {
portRef.current?.open();
(e.target as any).blur();
}}
/>
</SettingItem>
<SettingItem <SettingRow onClick={() => ctrlRef.current?.open()} label={<div className="flex items-center gap-3"><RadioTower className="h-4 w-4 text-muted-foreground" />{t("External Controller")} <TooltipIcon tooltip={t("Enable one-click random API port and key. Click to randomize the port and key")} /></div>} />
onClick={() => ctrlRef.current?.open()}
label={
<>
{t("External")}
<TooltipIcon
title={t(
"Enable one-click random API port and key. Click to randomize the port and key",
)}
sx={{ opacity: "0.7" }}
/>
</>
}
/>
<SettingItem onClick={() => webRef.current?.open()} label={t("Web UI")} /> <SettingRow onClick={() => webRef.current?.open()} label={<LabelWithIcon icon={LayoutDashboard} text={t("Yacd Web UI")} />} />
<SettingItem <SettingRow label={<LabelWithIcon icon={Cog} text={t("Clash Core")} />} extra={<TooltipIcon tooltip={t("Clash Core Settings")} icon={<Settings className="h-4 w-4"/>} onClick={() => coreRef.current?.open()} />}>
label={t("Clash Core")} <p className="text-sm font-medium pr-2 font-mono">{version}</p>
extra={ </SettingRow>
<TooltipIcon
icon={SettingsRounded}
onClick={() => coreRef.current?.open()}
/>
}
>
<Typography sx={{ py: "7px", pr: 1 }}>{version}</Typography>
</SettingItem>
{isWIN && ( {isWIN && <SettingRow onClick={useLockFn(invoke_uwp_tool)} label={<LabelWithIcon icon={Repeat} text={t("UWP Loopback Tool")} />} extra={<TooltipIcon tooltip={t("Open UWP tool Info")} />} />}
<SettingItem
onClick={invoke_uwp_tool}
label={t("Open UWP tool")}
extra={
<TooltipIcon
title={t("Open UWP tool Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
)}
<SettingItem onClick={onUpdateGeo} label={t("Update GeoData")} /> <SettingRow onClick={onUpdateGeo} label={<LabelWithIcon icon={MapIcon} text={t("Update GeoData")} />} />
</SettingList> </div>
</div>
); );
}; };

View File

@@ -1,267 +1,180 @@
import { mutate } from "swr"; import { useRef, useState } from "react";
import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { useLockFn } from "ahooks";
SettingsRounded, import { mutate } from "swr";
PlayArrowRounded, import { invoke } from "@tauri-apps/api/core";
PauseRounded, import getSystem from "@/utils/get-system";
WarningRounded,
BuildRounded, // Сервисы и хуки
DeleteForeverRounded,
} from "@mui/icons-material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; import { useSystemProxyState } from "@/hooks/use-system-proxy-state"; // Ваш хук
import { useSystemState } from "@/hooks/use-system-state";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
import { uninstallService, restartCore, stopCore, invoke_uwp_tool } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
// Компоненты
import { DialogRef, Switch } from "@/components/base"; import { DialogRef, Switch } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp"; import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { GuardState } from "./mods/guard-state"; import { GuardState } from "./mods/guard-state";
// Иконки
import { Settings, PlayCircle, PauseCircle, AlertTriangle, Wrench, Trash2, Funnel, Monitor, Power, BellOff, Repeat } from "lucide-react";
// Модальные окна
import { SysproxyViewer } from "./mods/sysproxy-viewer"; import { SysproxyViewer } from "./mods/sysproxy-viewer";
import { TunViewer } from "./mods/tun-viewer"; import { TunViewer } from "./mods/tun-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { uninstallService, restartCore, stopCore } from "@/services/cmds";
import { useLockFn } from "ahooks";
import { Button, Tooltip } from "@mui/material";
import { useSystemState } from "@/hooks/use-system-state";
import { showNotice } from "@/services/noticeService"; const isWIN = getSystem() === "windows";
import { useServiceInstaller } from "@/hooks/useServiceInstaller"; interface Props { onError?: (err: Error) => void; }
interface Props { const SettingRow = ({ label, extra, children, onClick }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; }) => (
onError?: (err: Error) => void; <div className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`} onClick={onClick}>
} <div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
</div>
);
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
};
const SettingSystem = ({ onError }: Props) => { const SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const { verge, mutateVerge, patchVerge } = useVerge();
const { installServiceAndRestartCore } = useServiceInstaller(); const { installServiceAndRestartCore } = useServiceInstaller();
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Используем синтаксис переименования: `actualState` становится `systemProxyActualState`
const { const {
actualState: systemProxyActualState, actualState: systemProxyActualState,
indicator: systemProxyIndicator, indicator: systemProxyIndicator,
toggleSystemProxy, toggleSystemProxy,
} = useSystemProxyState(); } = useSystemProxyState();
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
const { isAdminMode, isServiceMode, mutateRunningMode } = useSystemState(); const { isAdminMode, isServiceMode, mutateRunningMode } = useSystemState();
// +++ isTunAvailable 现在使用 SWR 的 isServiceMode
const isTunAvailable = isServiceMode || isAdminMode; const isTunAvailable = isServiceMode || isAdminMode;
const sysproxyRef = useRef<DialogRef>(null); const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null); const tunRef = useRef<DialogRef>(null);
const { enable_tun_mode, enable_auto_launch, enable_silent_start } = const { enable_tun_mode, enable_auto_launch, enable_silent_start } = verge ?? {};
verge ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value; const onSwitchFormat = (val: boolean) => val;
const onChangeData = (patch: Partial<IVergeConfig>) => { const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false); mutateVerge({ ...verge, ...patch }, false);
}; };
// 抽象服务操作逻辑 const handleServiceOperation = useLockFn(async ({ beforeMsg, action, actionMsg, successMsg }: { beforeMsg: string; action: () => Promise<void>; actionMsg: string; successMsg: string; }) => {
const handleServiceOperation = useLockFn( try {
async ({ showNotice("info", beforeMsg);
beforeMsg, await stopCore();
action, showNotice("info", actionMsg);
actionMsg, await action();
successMsg, showNotice("success", successMsg);
}: { showNotice("info", t("Restarting Core..."));
beforeMsg: string; await restartCore();
action: () => Promise<void>; await mutateRunningMode();
actionMsg: string; } catch (err: any) {
successMsg: string; showNotice("error", err.message || err.toString());
}) => {
try { try {
showNotice("info", beforeMsg); showNotice("info", t("Try running core as Sidecar..."));
await stopCore();
showNotice("info", actionMsg);
await action();
showNotice("success", successMsg);
showNotice("info", t("Restarting Core..."));
await restartCore(); await restartCore();
await mutateRunningMode(); await mutateRunningMode();
} catch (err: any) { } catch (e: any) {
showNotice("error", err.message || err.toString()); showNotice("error", e?.message || e?.toString());
try {
showNotice("info", t("Try running core as Sidecar..."));
await restartCore();
await mutateRunningMode();
} catch (e: any) {
showNotice("error", e?.message || e?.toString());
}
} }
}, }
); });
// 卸载系统服务 const onUninstallService = () => handleServiceOperation({
const onUninstallService = () =>
handleServiceOperation({
beforeMsg: t("Stopping Core..."), beforeMsg: t("Stopping Core..."),
action: uninstallService, action: uninstallService,
actionMsg: t("Uninstalling Service..."), actionMsg: t("Uninstalling Service..."),
successMsg: t("Service Uninstalled Successfully"), successMsg: t("Service Uninstalled Successfully"),
}); });
return ( return (
<SettingList title={t("System Setting")}> <div>
<SysproxyViewer ref={sysproxyRef} /> <h3 className="text-lg font-medium mb-4">{t("System Setting")}</h3>
<TunViewer ref={tunRef} /> <div className="space-y-1">
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<SettingItem <SettingRow
label={t("Tun Mode")} label={<LabelWithIcon icon={Funnel} text={t("Tun Mode")} />}
extra={ extra={
<> <div className="flex items-center gap-1">
<TooltipIcon <TooltipIcon tooltip={t("Tun Mode Info")} icon={<Settings className="h-4 w-4" />} onClick={() => tunRef.current?.open()} />
title={t("Tun Mode Info")} {!isTunAvailable && <TooltipProvider><Tooltip><TooltipTrigger><AlertTriangle className="h-4 w-4 text-amber-500" /></TooltipTrigger><TooltipContent><p>{t("TUN requires Service Mode or Admin Mode")}</p></TooltipContent></Tooltip></TooltipProvider>}
icon={SettingsRounded} {!isServiceMode && !isAdminMode && <TooltipProvider><Tooltip><TooltipTrigger asChild><Button variant="outline" size="icon" className="h-7 w-7" onClick={installServiceAndRestartCore}><Wrench className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent><p>{t("Install Service")}</p></TooltipContent></Tooltip></TooltipProvider>}
onClick={() => tunRef.current?.open()} {isServiceMode && <TooltipProvider><Tooltip><TooltipTrigger asChild><Button variant="destructive" size="icon" className="h-7 w-7" onClick={onUninstallService}><Trash2 className="h-4 w-4" /></Button></TooltipTrigger><TooltipContent><p>{t("Uninstall Service")}</p></TooltipContent></Tooltip></TooltipProvider>}
/> </div>
{!isTunAvailable && ( }
<Tooltip title={t("TUN requires Service Mode or Admin Mode")}>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} />
</Tooltip>
)}
{!isServiceMode && !isAdminMode && (
<Tooltip title={t("Install Service")}>
<Button
variant="outlined"
color="primary"
size="small"
onClick={installServiceAndRestartCore}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<BuildRounded fontSize="small" />
</Button>
</Tooltip>
)}
{isServiceMode && (
<Tooltip title={t("Uninstall Service")}>
<Button
// variant="outlined"
color="secondary"
size="small"
onClick={onUninstallService}
sx={{ mr: 1, minWidth: "32px", p: "4px" }}
>
<DeleteForeverRounded fontSize="small" />
</Button>
</Tooltip>
)}
</>
}
>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
if (!isTunAvailable) return;
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (!isTunAvailable) {
showNotice("error", t("TUN requires Service Mode or Admin Mode"));
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
> >
<Switch edge="end" disabled={!isTunAvailable} /> <GuardState
</GuardState> value={enable_tun_mode ?? false}
</SettingItem> valueProps="checked"
<SettingItem onChangeProps="onCheckedChange"
label={t("System Proxy")} onFormat={onSwitchFormat}
extra={ onChange={(e) => onChangeData({ enable_tun_mode: e })}
<> onGuard={(e) => { if (!isTunAvailable) { showNotice("error", t("TUN requires Service Mode or Admin Mode")); return Promise.reject(new Error(t("TUN requires Service Mode or Admin Mode"))); } return patchVerge({ enable_tun_mode: e }); }}
<TooltipIcon onCatch={onError}
title={t("System Proxy Info")} >
icon={SettingsRounded} <Switch disabled={!isTunAvailable} />
onClick={() => sysproxyRef.current?.open()} </GuardState>
/> </SettingRow>
{systemProxyIndicator ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)}
</>
}
>
<GuardState
value={systemProxyActualState}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
>
<Switch edge="end" checked={systemProxyActualState} />
</GuardState>
</SettingItem>
<SettingItem <SettingRow
label={t("Auto Launch")} label={<LabelWithIcon icon={Monitor} text={t("System Proxy")} />}
extra={ extra={
isAdminMode && ( <div className="flex items-center gap-2">
<Tooltip <TooltipIcon tooltip={t("System Proxy Info")} icon={<Settings className="h-4 w-4" />} onClick={() => sysproxyRef.current?.open()} />
title={t("Administrator mode may not support auto launch")} {systemProxyIndicator ? <PlayCircle className="h-5 w-5 text-green-500" /> : <PauseCircle className="h-5 w-5 text-red-500" />}
> </div>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} /> }
</Tooltip>
)
}
>
<GuardState
value={enable_auto_launch ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => {
// 移除管理员模式检查提示
onChangeData({ enable_auto_launch: e });
}}
onGuard={async (e) => {
if (isAdminMode) {
showNotice(
"info",
t("Administrator mode may not support auto launch"),
);
}
try {
// 先触发UI更新立即看到反馈
onChangeData({ enable_auto_launch: e });
await patchVerge({ enable_auto_launch: e });
await mutate("getAutoLaunchStatus");
return Promise.resolve();
} catch (error) {
// 如果出错,恢复原始状态
onChangeData({ enable_auto_launch: !e });
return Promise.reject(error);
}
}}
> >
<Switch edge="end" /> <GuardState value={systemProxyActualState} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onGuard={(e) => toggleSystemProxy(e)} onCatch={onError}>
</GuardState> <Switch />
</SettingItem> </GuardState>
</SettingRow>
<SettingItem <SettingRow
label={t("Silent Start")} label={<LabelWithIcon icon={Power} text={t("Auto Launch")} />}
extra={ extra={isAdminMode && <TooltipProvider><Tooltip><TooltipTrigger><AlertTriangle className="h-4 w-4 text-amber-500" /></TooltipTrigger><TooltipContent><p>{t("Administrator mode may not support auto launch")}</p></TooltipContent></Tooltip></TooltipProvider>}
<TooltipIcon title={t("Silent Start Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_silent_start: e })}
onGuard={(e) => patchVerge({ enable_silent_start: e })}
> >
<Switch edge="end" /> <GuardState
</GuardState> value={enable_auto_launch ?? false}
</SettingItem> valueProps="checked"
</SettingList> onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_auto_launch: e })}
onGuard={async (e) => { if (isAdminMode) { showNotice("info", t("Administrator mode may not support auto launch")); } try { onChangeData({ enable_auto_launch: e }); await patchVerge({ enable_auto_launch: e }); await mutate("getAutoLaunchStatus"); return Promise.resolve(); } catch (error) { onChangeData({ enable_auto_launch: !e }); return Promise.reject(error); } }}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={BellOff} text={t("Silent Start")} />} extra={<TooltipIcon tooltip={t("Silent Start Info")} />}>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_silent_start: e })}
onGuard={(e) => patchVerge({ enable_silent_start: e })}
onCatch={onError}
>
<Switch />
</GuardState>
</SettingRow>
</div>
</div>
); );
}; };

View File

@@ -1,6 +1,9 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Typography } from "@mui/material"; import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { version } from "@root/package.json";
// Сервисы и хуки
import { import {
exitApp, exitApp,
openAppDir, openAppDir,
@@ -9,11 +12,31 @@ import {
openDevTools, openDevTools,
exportDiagnosticInfo, exportDiagnosticInfo,
} from "@/services/cmds"; } from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater"; import { showNotice } from "@/services/noticeService";
import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json"; // Компоненты
import { DialogRef } from "@/components/base"; import { DialogRef } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp"; import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
// --- НАЧАЛО ИЗМЕНЕНИЙ 1: Импортируем все нужные иконки ---
import {
Settings,
Copy,
Info,
Archive,
FileCode,
Folder,
FolderCog,
FolderClock,
RefreshCw,
Terminal,
Feather,
LogOut,
ClipboardList,
} from "lucide-react";
// Модальные окна
import { ConfigViewer } from "./mods/config-viewer"; import { ConfigViewer } from "./mods/config-viewer";
import { HotkeyViewer } from "./mods/hotkey-viewer"; import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer"; import { MiscViewer } from "./mods/misc-viewer";
@@ -22,18 +45,43 @@ import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer"; import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer"; import { BackupViewer } from "./mods/backup-viewer";
import { LiteModeViewer } from "./mods/lite-mode-viewer"; import { LiteModeViewer } from "./mods/lite-mode-viewer";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { showNotice } from "@/services/noticeService";
interface Props { interface Props {
onError?: (err: Error) => void; onError?: (err: Error) => void;
} }
// Наш переиспользуемый компонент для строки настроек
const SettingRow = ({
label,
extra,
children,
onClick,
}: {
label: React.ReactNode;
extra?: React.ReactNode;
children?: React.ReactNode;
onClick?: () => void;
}) => (
<div
className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`}
onClick={onClick}
>
<div className="flex items-center gap-2">
{/* Мы ожидаем, что label теперь может быть сложным компонентом */}
<div className="text-sm font-medium">{label}</div>
{extra && <div className="text-muted-foreground">{extra}</div>}
</div>
<div>
{children}
</div>
</div>
);
const SettingVergeAdvanced = ({ onError }: Props) => { const SettingVergeAdvanced = ({ onError }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const configRef = useRef<DialogRef>(null); const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null); const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null); const miscRef = useRef<DialogRef>(null);
@@ -61,84 +109,51 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
showNotice("success", t("Copy Success"), 1000); showNotice("success", t("Copy Success"), 1000);
}, []); }, []);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return (
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
return ( return (
<SettingList title={t("Verge Advanced Setting")}> <div>
<ThemeViewer ref={themeRef} /> <h3 className="text-lg font-medium mb-4">{t("Verge Advanced Setting")}</h3>
<ConfigViewer ref={configRef} /> <div className="space-y-1">
<HotkeyViewer ref={hotkeyRef} /> <ThemeViewer ref={themeRef} />
<MiscViewer ref={miscRef} /> <ConfigViewer ref={configRef} />
<LayoutViewer ref={layoutRef} /> <HotkeyViewer ref={hotkeyRef} />
<UpdateViewer ref={updateRef} /> <MiscViewer ref={miscRef} />
<BackupViewer ref={backupRef} /> <LayoutViewer ref={layoutRef} />
<LiteModeViewer ref={liteModeRef} /> <UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<LiteModeViewer ref={liteModeRef} />
<SettingItem {/* --- НАЧАЛО ИЗМЕНЕНИЙ 2: Добавляем иконки к каждому пункту --- */}
onClick={() => backupRef.current?.open()} <SettingRow onClick={() => backupRef.current?.open()} label={<LabelWithIcon icon={Archive} text={t("Backup Setting")} />} extra={<TooltipIcon tooltip={t("Backup Setting Info")} />} />
label={t("Backup Setting")} <SettingRow onClick={() => configRef.current?.open()} label={<LabelWithIcon icon={FileCode} text={t("Runtime Config")} />} />
extra={ <SettingRow onClick={openAppDir} label={<LabelWithIcon icon={Folder} text={t("Open Conf Dir")} />} extra={<TooltipIcon tooltip={t("Open Conf Dir Info")} />} />
<TooltipIcon <SettingRow onClick={openCoreDir} label={<LabelWithIcon icon={FolderCog} text={t("Open Core Dir")} />} />
title={t("Backup Setting Info")} <SettingRow onClick={openLogsDir} label={<LabelWithIcon icon={FolderClock} text={t("Open Logs Dir")} />} />
sx={{ opacity: "0.7" }} <SettingRow onClick={onCheckUpdate} label={<LabelWithIcon icon={RefreshCw} text={t("Check for Updates")} />} />
/> <SettingRow onClick={openDevTools} label={<LabelWithIcon icon={Terminal} text={t("Open Dev Tools")} />} />
} <SettingRow label={<LabelWithIcon icon={Feather} text={t("LightWeight Mode Settings")} />} extra={<TooltipIcon tooltip={t("LightWeight Mode Info")} />} onClick={() => liteModeRef.current?.open()} />
/> <SettingRow onClick={exitApp} label={<LabelWithIcon icon={LogOut} text={t("Exit")} />} />
<SettingItem <SettingRow label={<LabelWithIcon icon={ClipboardList} text={t("Export Diagnostic Info")} />}>
onClick={() => configRef.current?.open()} <TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onExportDiagnosticInfo} />
label={t("Runtime Config")} </SettingRow>
/>
<SettingItem <SettingRow label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}>
onClick={openAppDir} <p className="text-sm font-medium pr-2 font-mono">v{version}</p>
label={t("Open Conf Dir")} </SettingRow>
extra={ {/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
<TooltipIcon </div>
title={t("Open Conf Dir Info")} </div>
sx={{ opacity: "0.7" }}
/>
}
/>
<SettingItem onClick={openCoreDir} label={t("Open Core Dir")} />
<SettingItem onClick={openLogsDir} label={t("Open Logs Dir")} />
<SettingItem onClick={onCheckUpdate} label={t("Check for Updates")} />
<SettingItem onClick={openDevTools} label={t("Open Dev Tools")} />
<SettingItem
label={t("LightWeight Mode Settings")}
extra={
<TooltipIcon
title={t("LightWeight Mode Info")}
sx={{ opacity: "0.7" }}
/>
}
onClick={() => liteModeRef.current?.open()}
/>
<SettingItem
onClick={() => {
exitApp();
}}
label={t("Exit")}
/>
<SettingItem
label={t("Export Diagnostic Info")}
extra={
<TooltipIcon
icon={ContentCopyRounded}
onClick={onExportDiagnosticInfo}
/>
}
></SettingItem>
<SettingItem label={t("Verge Version")}>
<Typography sx={{ py: "7px", pr: 1 }}>v{version}</Typography>
</SettingItem>
</SettingList>
); );
}; };

View File

@@ -1,26 +1,36 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { Button, MenuItem, Select, Input } from "@mui/material";
import { copyClashEnv } from "@/services/cmds"; import { copyClashEnv } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { DialogRef } from "@/components/base"; import { languages } from "@/services/i18n";
import { SettingList, SettingItem } from "./mods/setting-comp"; import { showNotice } from "@/services/noticeService";
import { ThemeModeSwitch } from "./mods/theme-mode-switch"; import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
// Компоненты
import { DialogRef, Switch } from "@/components/base";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { GuardState } from "./mods/guard-state";
import { ThemeModeSwitch } from "./mods/theme-mode-switch"; // Импортируем наш новый компонент
// Иконки
import {
Copy, Languages, Palette, MousePointerClick, Terminal, Home, FileTerminal,
SwatchBook, LayoutTemplate, Sparkles, Keyboard
} from "lucide-react";
// Модальные окна
import { ConfigViewer } from "./mods/config-viewer"; import { ConfigViewer } from "./mods/config-viewer";
import { HotkeyViewer } from "./mods/hotkey-viewer"; import { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer"; import { MiscViewer } from "./mods/misc-viewer";
import { ThemeViewer } from "./mods/theme-viewer"; import { ThemeViewer } from "./mods/theme-viewer";
import { GuardState } from "./mods/guard-state";
import { LayoutViewer } from "./mods/layout-viewer"; import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer"; import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-viewer"; import { BackupViewer } from "./mods/backup-viewer";
import getSystem from "@/utils/get-system";
import { routers } from "@/pages/_routers";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { ContentCopyRounded } from "@mui/icons-material";
import { languages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
interface Props { interface Props {
onError?: (err: Error) => void; onError?: (err: Error) => void;
@@ -30,31 +40,29 @@ const OS = getSystem();
const languageOptions = Object.entries(languages).map(([code, _]) => { const languageOptions = Object.entries(languages).map(([code, _]) => {
const labels: { [key: string]: string } = { const labels: { [key: string]: string } = {
en: "English", en: "English", ru: "Русский", zh: "中文", fa: "فارسی", tt: "Татар", id: "Bahasa Indonesia",
ru: "Русский", ar: "العربية", ko: "한국어", tr: "Türkçe",
zh: "中文",
fa: "فارسی",
tt: "Татар",
id: "Bahasa Indonesia",
ar: "العربية",
ko: "한국어",
tr: "Türkçe",
}; };
return { code, label: labels[code] }; return { code, label: labels[code] || code };
}); });
const SettingRow = ({ label, extra, children, onClick }: { label: React.ReactNode; extra?: React.ReactNode; children?: React.ReactNode; onClick?: () => void; }) => (
<div className={`flex items-center justify-between py-3 border-b border-border last:border-b-0 ${onClick ? 'cursor-pointer hover:bg-accent/50 -mx-3 px-3 rounded-md' : ''}`} onClick={onClick}>
<div className="flex items-center gap-2"><div className="text-sm font-medium">{label}</div>{extra && <div className="text-muted-foreground">{extra}</div>}</div>
<div>{children}</div>
</div>
);
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return ( <span className="flex items-center gap-3"><Icon className="h-4 w-4 text-muted-foreground" />{text}</span> );
};
const SettingVergeBasic = ({ onError }: Props) => { const SettingVergeBasic = ({ onError }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge(); const { verge, patchVerge, mutateVerge } = useVerge();
const { const { theme_mode, language, tray_event, env_type, startup_script, start_page } = verge ?? {};
theme_mode,
language,
tray_event,
env_type,
startup_script,
start_page,
} = verge ?? {};
const configRef = useRef<DialogRef>(null); const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null); const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null); const miscRef = useRef<DialogRef>(null);
@@ -70,181 +78,103 @@ const SettingVergeBasic = ({ onError }: Props) => {
const onCopyClashEnv = useCallback(async () => { const onCopyClashEnv = useCallback(async () => {
await copyClashEnv(); await copyClashEnv();
showNotice("success", t("Copy Success"), 1000); showNotice("success", t("Copy Success"), 1000);
}, []); }, [t]);
return ( return (
<SettingList title={t("Verge Basic Setting")}> <div>
<ThemeViewer ref={themeRef} /> <h3 className="text-lg font-medium mb-4">{t("Verge Basic Setting")}</h3>
<ConfigViewer ref={configRef} /> <div className="space-y-1">
<HotkeyViewer ref={hotkeyRef} /> <ThemeViewer ref={themeRef} />
<MiscViewer ref={miscRef} /> <ConfigViewer ref={configRef} />
<LayoutViewer ref={layoutRef} /> <HotkeyViewer ref={hotkeyRef} />
<UpdateViewer ref={updateRef} /> <MiscViewer ref={miscRef} />
<BackupViewer ref={backupRef} /> <LayoutViewer ref={layoutRef} />
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem label={t("Language")}> <SettingRow label={<LabelWithIcon icon={Languages} text={t("Language")} />}>
<GuardState <GuardState value={language ?? "en"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ language: e })} onGuard={(e) => patchVerge({ language: e })}>
value={language ?? "en"} <Select onValueChange={(value) => onChangeData({ language: value })} value={language}>
onCatch={onError} <SelectTrigger className="w-32 h-8"><SelectValue /></SelectTrigger>
onFormat={(e: any) => e.target.value} <SelectContent>{languageOptions.map(({ code, label }) => (<SelectItem key={code} value={code}>{label}</SelectItem>))}</SelectContent>
onChange={(e) => onChangeData({ language: e })}
onGuard={(e) => patchVerge({ language: e })}
>
<Select size="small" sx={{ width: 110, "> div": { py: "7.5px" } }}>
{languageOptions.map(({ code, label }) => (
<MenuItem key={code} value={code}>
{label}
</MenuItem>
))}
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Theme Mode")}>
<GuardState
value={theme_mode}
onCatch={onError}
onChange={(e) => onChangeData({ theme_mode: e })}
onGuard={(e) => patchVerge({ theme_mode: e })}
>
<ThemeModeSwitch />
</GuardState>
</SettingItem>
{OS !== "linux" && (
<SettingItem label={t("Tray Click Event")}>
<GuardState
value={tray_event ?? "main_window"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ tray_event: e })}
onGuard={(e) => patchVerge({ tray_event: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="main_window">{t("Show Main Window")}</MenuItem>
<MenuItem value="tray_menu">{t("Show Tray Menu")}</MenuItem>
<MenuItem value="system_proxy">{t("System Proxy")}</MenuItem>
<MenuItem value="tun_mode">{t("Tun Mode")}</MenuItem>
<MenuItem value="disable">{t("Disable")}</MenuItem>
</Select> </Select>
</GuardState> </GuardState>
</SettingItem> </SettingRow>
)}
<SettingItem <SettingRow label={<LabelWithIcon icon={Palette} text={t("Theme Mode")} />}>
label={t("Copy Env Type")} <GuardState
extra={ value={theme_mode}
<TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} /> onCatch={onError}
} onChange={(e) => onChangeData({ theme_mode: e })}
> onGuard={(e) => patchVerge({ theme_mode: e })}
<GuardState >
value={env_type ?? (OS === "windows" ? "powershell" : "bash")} <ThemeModeSwitch />
onCatch={onError} </GuardState>
onFormat={(e: any) => e.target.value} </SettingRow>
onChange={(e) => onChangeData({ env_type: e })}
onGuard={(e) => patchVerge({ env_type: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
<MenuItem value="bash">Bash</MenuItem>
<MenuItem value="fish">Fish</MenuItem>
<MenuItem value="nushell">Nushell</MenuItem>
<MenuItem value="cmd">CMD</MenuItem>
<MenuItem value="powershell">PowerShell</MenuItem>
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Start Page")}> {OS !== "linux" && (
<GuardState <SettingRow label={<LabelWithIcon icon={MousePointerClick} text={t("Tray Click Event")} />}>
value={start_page ?? "/"} <GuardState value={tray_event ?? "main_window"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ tray_event: e })} onGuard={(e) => patchVerge({ tray_event: e })}>
onCatch={onError} <Select onValueChange={(value) => onChangeData({ tray_event: value })} value={tray_event}>
onFormat={(e: any) => e.target.value} <SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
onChange={(e) => onChangeData({ start_page: e })} <SelectContent>
onGuard={(e) => patchVerge({ start_page: e })} <SelectItem value="main_window">{t("Show Main Window")}</SelectItem>
> <SelectItem value="tray_menu">{t("Show Tray Menu")}</SelectItem>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}> <SelectItem value="system_proxy">{t("System Proxy")}</SelectItem>
{routers.map((page: { label: string; path: string }) => { <SelectItem value="tun_mode">{t("Tun Mode")}</SelectItem>
return ( <SelectItem value="disable">{t("Disable")}</SelectItem>
<MenuItem key={page.path} value={page.path}> </SelectContent>
{t(page.label)} </Select>
</MenuItem> </GuardState>
); </SettingRow>
})} )}
</Select>
</GuardState>
</SettingItem>
<SettingItem label={t("Startup Script")}> <SettingRow label={<LabelWithIcon icon={Copy} text={t("Copy Env Type")} />} extra={<TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onCopyClashEnv} />}>
<GuardState <GuardState value={env_type ?? (OS === "windows" ? "powershell" : "bash")} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ env_type: e })} onGuard={(e) => patchVerge({ env_type: e })}>
value={startup_script ?? ""} <Select onValueChange={(value) => onChangeData({ env_type: value })} value={env_type}>
onCatch={onError} <SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
onFormat={(e: any) => e.target.value} <SelectContent>
onChange={(e) => onChangeData({ startup_script: e })} <SelectItem value="bash">Bash</SelectItem>
onGuard={(e) => patchVerge({ startup_script: e })} <SelectItem value="fish">Fish</SelectItem>
> <SelectItem value="nushell">Nushell</SelectItem>
<Input <SelectItem value="cmd">CMD</SelectItem>
value={startup_script} <SelectItem value="powershell">PowerShell</SelectItem>
disabled </SelectContent>
disableUnderline </Select>
sx={{ width: 230 }} </GuardState>
endAdornment={ </SettingRow>
<>
<Button
onClick={async () => {
const selected = await open({
directory: false,
multiple: false,
filters: [
{
name: "Shell Script",
extensions: ["sh", "bat", "ps1"],
},
],
});
if (selected) {
onChangeData({ startup_script: `${selected}` });
patchVerge({ startup_script: `${selected}` });
}
}}
>
{t("Browse")}
</Button>
{startup_script && (
<Button
onClick={async () => {
onChangeData({ startup_script: "" });
patchVerge({ startup_script: "" });
}}
>
{t("Clear")}
</Button>
)}
</>
}
></Input>
</GuardState>
</SettingItem>
<SettingItem <SettingRow label={<LabelWithIcon icon={Home} text={t("Start Page")} />}>
onClick={() => themeRef.current?.open()} <GuardState value={start_page ?? "/"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ start_page: e })} onGuard={(e) => patchVerge({ start_page: e })}>
label={t("Theme Setting")} <Select onValueChange={(value) => onChangeData({ start_page: value })} value={start_page}>
/> <SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{routers
.filter((page) => !!page.label) // 1. Оставляем только страницы, у которых есть `label`
.map((page) => ( // 2. Теперь TypeScript уверен, что у `page` есть `label`
<SelectItem key={page.path} value={page.path}>
{t(page.label!)}
</SelectItem>
))}
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingItem <SettingRow label={<LabelWithIcon icon={FileTerminal} text={t("Startup Script")} />}>
onClick={() => layoutRef.current?.open()} <div className="flex items-center gap-2">
label={t("Layout Setting")} <Input readOnly value={startup_script ?? ""} placeholder={t("Not Set")} className="h-8 flex-1" />
/> <Button variant="outline" size="sm" className="h-8" onClick={async () => { const selected = await open({ directory: false, multiple: false, filters: [{ name: "Shell Script", extensions: ["sh", "bat", "ps1"] }] }); if (selected) { const path = Array.isArray(selected) ? selected[0] : selected; onChangeData({ startup_script: path }); patchVerge({ startup_script: path }); } }}>{t("Browse")}</Button>
{startup_script && <Button variant="destructive" size="sm" className="h-8" onClick={async () => { onChangeData({ startup_script: "" }); patchVerge({ startup_script: "" }); }}>{t("Clear")}</Button>}
</div>
</SettingRow>
<SettingItem <SettingRow onClick={() => themeRef.current?.open()} label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />} />
onClick={() => miscRef.current?.open()} <SettingRow onClick={() => layoutRef.current?.open()} label={<LabelWithIcon icon={LayoutTemplate} text={t("Layout Setting")} />} />
label={t("Miscellaneous")} <SettingRow onClick={() => miscRef.current?.open()} label={<LabelWithIcon icon={Sparkles} text={t("Miscellaneous")} />} />
/> <SettingRow onClick={() => hotkeyRef.current?.open()} label={<LabelWithIcon icon={Keyboard} text={t("Hotkey Setting")} />} />
</div>
<SettingItem </div>
onClick={() => hotkeyRef.current?.open()}
label={t("Hotkey Setting")}
/>
</SettingList>
); );
}; };

View File

@@ -1,54 +1,35 @@
import { useRef } from "react"; import { useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useSWR from "swr"; import useSWR from "swr";
import { import { useLockFn } from "ahooks";
SettingsRounded, import { closeAllConnections } from "@/services/api";
PlayCircleOutlineRounded, import { showNotice } from "@/services/noticeService";
PauseCircleOutlineRounded, import { useVerge } from "@/hooks/use-verge";
BuildRounded, import { useServiceInstaller } from "@/hooks/useServiceInstaller";
} from "@mui/icons-material"; import { getRunningMode } from "@/services/cmds";
import { import { cn } from "@root/lib/utils";
Box,
Button, // Новые импорты
Tooltip, import { Button } from "@/components/ui/button";
Typography, import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
alpha, import { Switch } from "@/components/base";
useTheme, import { DialogRef } from "@/components/base";
} from "@mui/material";
import { DialogRef, Switch } from "@/components/base";
import { GuardState } from "@/components/setting/mods/guard-state"; import { GuardState } from "@/components/setting/mods/guard-state";
import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer"; import { SysproxyViewer } from "@/components/setting/mods/sysproxy-viewer";
import { TunViewer } from "@/components/setting/mods/tun-viewer"; import { TunViewer } from "@/components/setting/mods/tun-viewer";
import { useVerge } from "@/hooks/use-verge"; import { Settings, PlayCircle, PauseCircle, Wrench } from "lucide-react";
import { useSystemProxyState } from "@/hooks/use-system-proxy-state";
import { getRunningMode } from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
interface ProxySwitchProps { interface ProxySwitchProps {
label?: string; label?: string;
onError?: (err: Error) => void; onError?: (err: Error) => void;
} }
/**
* 可复用的代理控制开关组件
* 包含 Tun Mode 和 System Proxy 的开关功能
*/
const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => { const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge(); const { verge, mutateVerge, patchVerge } = useVerge();
const theme = useTheme();
const { installServiceAndRestartCore } = useServiceInstaller(); const { installServiceAndRestartCore } = useServiceInstaller();
const {
actualState: systemProxyActualState,
indicator: systemProxyIndicator,
toggleSystemProxy,
} = useSystemProxyState();
const { data: runningMode } = useSWR("getRunningMode", getRunningMode); const { data: runningMode } = useSWR("getRunningMode", getRunningMode);
// 是否以sidecar模式运行
const isSidecarMode = runningMode === "Sidecar"; const isSidecarMode = runningMode === "Sidecar";
const sysproxyRef = useRef<DialogRef>(null); const sysproxyRef = useRef<DialogRef>(null);
@@ -56,208 +37,69 @@ const ProxyControlSwitches = ({ label, onError }: ProxySwitchProps) => {
const { enable_tun_mode, enable_system_proxy } = verge ?? {}; const { enable_tun_mode, enable_system_proxy } = verge ?? {};
// 确定当前显示哪个开关
const isSystemProxyMode = label === t("System Proxy") || !label; const isSystemProxyMode = label === t("System Proxy") || !label;
const isTunMode = label === t("Tun Mode"); const isTunMode = label === t("Tun Mode");
const onSwitchFormat = (_e: any, value: boolean) => value; const onChangeData = (patch: Partial<IVergeConfig>) => mutateVerge({ ...verge, ...patch }, false);
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
// 安装系统服务
const onInstallService = installServiceAndRestartCore; const onInstallService = installServiceAndRestartCore;
return ( return (
<Box> <TooltipProvider delayDuration={100}>
{label && ( <div className="space-y-2">
<Box {/* Системный прокси */}
sx={{ {isSystemProxyMode && (
fontSize: "15px", <div className={cn("flex items-center justify-between p-2 rounded-lg transition-colors", enable_system_proxy && "bg-green-500/10")}>
fontWeight: "500", <div className="flex items-center gap-3">
mb: 0.5, {enable_system_proxy ? <PlayCircle className="h-7 w-7 text-green-600" /> : <PauseCircle className="h-7 w-7 text-muted-foreground" />}
display: "none", <div>
}} <p className="font-semibold text-sm">{t("System Proxy")}</p>
> <p className="text-xs text-muted-foreground">{t("Enable this for most users")}</p>
{label} </div>
</Box> </div>
)} <div className="flex items-center gap-1">
<Tooltip>
{/* 仅显示当前选中的开关 */} <TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => sysproxyRef.current?.open()}><Settings className="h-4 w-4" /></Button></TooltipTrigger>
{isSystemProxyMode && ( <TooltipContent><p>{t("System Proxy Info")}</p></TooltipContent>
<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" }}>
{systemProxyIndicator ? (
<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={systemProxyActualState}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
>
<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>
</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>
)} <GuardState value={enable_system_proxy ?? false} valueProps="checked" onCatch={onError} onFormat={(e) => e} onChange={(e) => onChangeData({ enable_system_proxy: e })} onGuard={async (e) => { if (!e && verge?.auto_close_connection) { closeAllConnections(); } await patchVerge({ enable_system_proxy: e }); }}>
<Switch />
</GuardState>
</div>
</div>
)}
<Tooltip title={t("Tun Mode Info")} arrow> {/* TUN режим */}
<Box {isTunMode && (
sx={{ <div className={cn("flex items-center justify-between p-2 rounded-lg transition-colors", enable_tun_mode && "bg-green-500/10", isSidecarMode && "opacity-60")}>
mr: 1, <div className="flex items-center gap-3">
color: "text.secondary", {enable_tun_mode ? <PlayCircle className="h-7 w-7 text-green-600" /> : <PauseCircle className="h-7 w-7 text-muted-foreground" />}
"&:hover": { color: "primary.main" }, <div>
cursor: "pointer", <p className="font-semibold text-sm">{t("Tun Mode")}</p>
}} <p className="text-xs text-muted-foreground">{t("System-level virtual network adapter")}</p>
onClick={() => tunRef.current?.open()} </div>
> </div>
<SettingsRounded fontSize="small" /> <div className="flex items-center gap-1">
</Box> {isSidecarMode && (
</Tooltip> <Tooltip>
<TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={onInstallService}><Wrench className="h-4 w-4" /></Button></TooltipTrigger>
<TooltipContent><p>{t("Install Service")}</p></TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => tunRef.current?.open()}><Settings className="h-4 w-4" /></Button></TooltipTrigger>
<TooltipContent><p>{t("Tun Mode Info")}</p></TooltipContent>
</Tooltip>
<GuardState value={enable_tun_mode ?? false} valueProps="checked" onCatch={onError} onFormat={(e) => e} onChange={(e) => { if (isSidecarMode) return Promise.reject(); onChangeData({ enable_tun_mode: e }); }} onGuard={(e) => { if (isSidecarMode) { showNotice("error", t("TUN requires Service Mode or Admin Mode")); return Promise.reject(); } return patchVerge({ enable_tun_mode: e }); }}>
<Switch disabled={isSidecarMode} />
</GuardState>
</div>
</div>
)}
<GuardState <SysproxyViewer ref={sysproxyRef} />
value={enable_tun_mode ?? false} <TunViewer ref={tunRef} />
valueProps="checked" </div>
onCatch={onError} </TooltipProvider>
onFormat={onSwitchFormat}
onChange={(e) => {
if (isSidecarMode) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
onChangeData({ enable_tun_mode: e });
}}
onGuard={(e) => {
if (isSidecarMode) {
showNotice(
"error",
t("TUN requires Service Mode or Admin Mode"),
);
return Promise.reject(
new Error(t("TUN requires Service Mode or Admin Mode")),
);
}
return patchVerge({ enable_tun_mode: e });
}}
>
<Switch edge="end" disabled={isSidecarMode} />
</GuardState>
</Box>
</Box>
)}
{/* 引用对话框组件 */}
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
</Box>
); );
}; };

View File

@@ -1,44 +1,41 @@
import { alpha, Box, styled } from "@mui/material"; import * as React from "react";
import { cn } from "@root/lib/utils"; // Утилита для объединения классов
export const TestBox = styled(Box)(({ theme, "aria-selected": selected }) => { // Определяем пропсы для нашего компонента.
const { mode, primary, text } = theme.palette; // Он принимает все стандартные атрибуты для div, а также `selected`.
const key = `${mode}-${!!selected}`; export interface TestBoxProps extends React.HTMLAttributes<HTMLDivElement> {
selected?: boolean;
}
const backgroundColor = export const TestBox = React.forwardRef<HTMLDivElement, TestBoxProps>(
mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08); ({ className, selected, children, ...props }, ref) => {
return (
<div
ref={ref}
// Устанавливаем data-атрибут в зависимости от пропса `selected`
data-selected={selected}
// Объединяем классы для создания сложной стилизации
className={cn(
// --- Базовые стили ---
"relative w-full cursor-pointer rounded-lg p-4 shadow-sm transition-all duration-200",
const color = { // --- Стили по умолчанию (не выбран) ---
"light-true": text.secondary, "bg-primary/5 text-muted-foreground",
"light-false": text.secondary, "hover:bg-primary/10 hover:shadow-md",
"dark-true": alpha(text.secondary, 0.65),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
const h2color = { // --- Стили для ВЫБРАННОГО состояния ---
"light-true": primary.main, // Используем data-атрибут для стилизации
"light-false": text.primary, "data-[selected=true]:bg-primary/20 data-[selected=true]:text-primary data-[selected=true]:shadow-lg",
"dark-true": primary.main,
"dark-false": text.primary,
}[key]!;
return { // --- Дополнительные классы от пользователя ---
position: "relative", className
width: "100%", )}
display: "block", {...props}
cursor: "pointer", >
textAlign: "left", {children}
borderRadius: 8, </div>
boxShadow: theme.shadows[1], );
padding: "8px 16px", }
boxSizing: "border-box", );
backgroundColor,
color, TestBox.displayName = "TestBox";
"& 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,16 +3,28 @@ import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { Box, Divider, MenuItem, Menu, styled, alpha } from "@mui/material";
import { BaseLoading } from "@/components/base";
import { LanguageRounded } from "@mui/icons-material";
import { showNotice } from "@/services/noticeService";
import { TestBox } from "./test-box";
import delayManager from "@/services/delay";
import { cmdTestDelay, downloadIconCache } from "@/services/cmds";
import { UnlistenFn } from "@tauri-apps/api/event"; import { UnlistenFn } from "@tauri-apps/api/event";
import { convertFileSrc } from "@tauri-apps/api/core"; import { convertFileSrc } from "@tauri-apps/api/core";
import { useListen } from "@/hooks/use-listen"; import { useListen } from "@/hooks/use-listen";
import { showNotice } from "@/services/noticeService";
import delayManager from "@/services/delay";
import { cmdTestDelay, downloadIconCache } from "@/services/cmds";
// Новые импорты
import { BaseLoading } from "@/components/base";
import { TestBox } from "./test-box"; // Наш рефакторенный компонент
import { Separator } from "@/components/ui/separator";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import { Languages } from "lucide-react"; // Новая иконка
// Вспомогательная функция для цвета задержки
const getDelayColorClass = (delay: number): string => {
if (delay < 0 || delay >= 10000) return "text-destructive";
if (delay >= 500) return "text-destructive";
if (delay >= 200) return "text-yellow-500";
return "text-green-500";
};
interface Props { interface Props {
id: string; id: string;
@@ -23,34 +35,21 @@ interface Props {
export const TestItem = (props: Props) => { export const TestItem = (props: Props) => {
const { itemData, onEdit, onDelete: onDeleteItem } = props; const { itemData, onEdit, onDelete: onDeleteItem } = props;
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id });
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.id,
});
const { t } = useTranslation(); const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [delay, setDelay] = useState(-1); const [delay, setDelay] = useState(-1);
const { uid, name, icon, url } = itemData; const { uid, name, icon, url } = itemData;
const [iconCachePath, setIconCachePath] = useState(""); const [iconCachePath, setIconCachePath] = useState("");
const { addListener } = useListen(); const { addListener } = useListen();
const onDelay = async () => { const onDelay = useLockFn(async () => {
setDelay(-2); setDelay(-2); // Состояние загрузки
const result = await cmdTestDelay(url); const result = await cmdTestDelay(url);
setDelay(result); setDelay(result);
}; });
useEffect(() => { const getFileName = (url: string) => url.substring(url.lastIndexOf("/") + 1);
initIconCachePath();
}, [icon]);
async function initIconCachePath() { async function initIconCachePath() {
if (icon && icon.trim().startsWith("http")) { if (icon && icon.trim().startsWith("http")) {
@@ -60,17 +59,9 @@ export const TestItem = (props: Props) => {
} }
} }
function getFileName(url: string) { useEffect(() => { initIconCachePath(); }, [icon]);
return url.substring(url.lastIndexOf("/") + 1);
}
const onEditTest = () => {
setAnchorEl(null);
onEdit();
};
const onDelete = useLockFn(async () => { const onDelete = useLockFn(async () => {
setAnchorEl(null);
try { try {
onDeleteItem(uid); onDeleteItem(uid);
} catch (err: any) { } catch (err: any) {
@@ -79,167 +70,73 @@ export const TestItem = (props: Props) => {
}); });
const menu = [ const menu = [
{ label: "Edit", handler: onEditTest }, { label: "Edit", handler: onEdit },
{ label: "Delete", handler: onDelete }, { label: "Delete", handler: onDelete, isDestructive: true },
]; ];
useEffect(() => { useEffect(() => {
let unlistenFn: UnlistenFn | null = null; let unlistenFn: UnlistenFn | null = null;
const setupListener = async () => { const setupListener = async () => {
if (unlistenFn) { if (unlistenFn) unlistenFn();
unlistenFn(); unlistenFn = await addListener("verge://test-all", onDelay);
}
unlistenFn = await addListener("verge://test-all", () => {
onDelay();
});
}; };
setupListener(); setupListener();
return () => { unlistenFn?.(); };
}, [url, addListener, onDelay]);
return () => { const style = {
if (unlistenFn) { transform: CSS.Transform.toString(transform),
console.log( transition,
`TestItem for ${props.id} unmounting or url changed, cleaning up test-all listener.`, zIndex: isDragging ? 100 : undefined,
); };
unlistenFn();
}
};
}, [url, addListener, onDelay, props.id]);
return ( return (
<Box <div style={style} ref={setNodeRef} {...attributes}>
sx={{ <ContextMenu>
position: "relative", <ContextMenuTrigger>
transform: CSS.Transform.toString(transform), <TestBox>
transition, {/* Мы применяем `listeners` к иконке, чтобы за нее можно было таскать */}
zIndex: isDragging ? "calc(infinity)" : undefined, <div {...listeners} className="flex h-12 cursor-move items-center justify-center">
}} {icon ? (
> <img
<TestBox src={icon.startsWith('data') ? icon : icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(icon)}` : (iconCachePath || icon)}
onContextMenu={(event) => { className="h-10"
const { clientX, clientY } = event; alt={name}
setPosition({ top: clientY, left: clientX }); />
setAnchorEl(event.currentTarget); ) : (
event.preventDefault(); <Languages className="h-10 w-10 text-muted-foreground" />
}}
>
<Box
position="relative"
sx={{ cursor: "move" }}
ref={setNodeRef}
{...attributes}
{...listeners}
>
{icon && icon.trim() !== "" ? (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{icon.trim().startsWith("http") && (
<img
src={iconCachePath === "" ? icon : iconCachePath}
height="40px"
/>
)} )}
{icon.trim().startsWith("data") && ( </div>
<img src={icon} height="40px" />
)}
{icon.trim().startsWith("<svg") && (
<img
src={`data:image/svg+xml;base64,${btoa(icon)}`}
height="40px"
/>
)}
</Box>
) : (
<Box sx={{ display: "flex", justifyContent: "center" }}>
<LanguageRounded sx={{ height: "40px" }} fontSize="large" />
</Box>
)}
<Box sx={{ display: "flex", justifyContent: "center" }}>{name}</Box> <p className="mt-1 text-center text-sm font-semibold truncate" title={name}>{name}</p>
</Box>
<Divider sx={{ marginTop: "8px" }} />
<Box
sx={{
display: "flex",
justifyContent: "center",
marginTop: "8px",
color: "primary.main",
}}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
{delay === -1 && ( <Separator className="my-2" />
<Widget
className="the-check" <div
onClick={(e) => { className="flex h-6 items-center justify-center text-sm font-medium"
e.preventDefault(); onClick={(e) => { e.stopPropagation(); onDelay(); }}
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
> >
{t("Test")} {delay === -2 ? (
</Widget> <BaseLoading className="h-4 w-4" />
)} ) : delay === -1 ? (
<span className="cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent">{t("Test")}</span>
) : (
<span className={`cursor-pointer rounded-md px-2 py-0.5 hover:bg-accent ${getDelayColorClass(delay)}`}>
{delayManager.formatDelay(delay)} ms
</span>
)}
</div>
</TestBox>
</ContextMenuTrigger>
{delay >= 0 && ( <ContextMenuContent>
// 显示延迟 {menu.map((item) => (
<Widget <ContextMenuItem key={item.label} onClick={item.handler} className={item.isDestructive ? "text-destructive" : ""}>
className="the-delay" {t(item.label)}
onClick={(e) => { </ContextMenuItem>
e.preventDefault(); ))}
e.stopPropagation(); </ContextMenuContent>
onDelay(); </ContextMenu>
}} </div>
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) => ({
":hover": {
bgcolor: alpha(palette.primary.main, 0.15),
},
})}
>
{delayManager.formatDelay(delay)}
</Widget>
)}
</Box>
</TestBox>
<Menu
open={!!anchorEl}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorPosition={position}
anchorReference="anchorPosition"
transitionDuration={225}
MenuListProps={{ sx: { py: 0.5 } }}
onContextMenu={(e) => {
setAnchorEl(null);
e.preventDefault();
}}
>
{menu.map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
{t(item.label)}
</MenuItem>
))}
</Menu>
</Box>
); );
}; };
const Widget = styled(Box)(({ theme: { typography } }) => ({
padding: "3px 6px",
fontSize: 14,
fontFamily: typography.fontFamily,
borderRadius: "4px",
}));

View File

@@ -1,13 +1,33 @@
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks"; import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form"; import { useForm } from "react-hook-form";
import { TextField } from "@mui/material";
import { useVerge } from "@/hooks/use-verge"; import { useVerge } from "@/hooks/use-verge";
import { BaseDialog } from "@/components/base";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { showNotice } from "@/services/noticeService"; import { showNotice } from "@/services/noticeService";
// Новые импорты из shadcn/ui и lucide-react
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface Props { interface Props {
onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void; onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void;
} }
@@ -17,7 +37,6 @@ export interface TestViewerRef {
edit: (item: IVergeTestItem) => void; edit: (item: IVergeTestItem) => void;
} }
// create or edit the test item
export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => { export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -25,146 +44,126 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge(); const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? []; const testList = verge?.test_list ?? [];
const { control, watch, register, ...formIns } = useForm<IVergeTestItem>({
defaultValues: { const form = useForm<IVergeTestItem>({
name: "", defaultValues: { name: "", icon: "", url: "" },
icon: "",
url: "",
},
}); });
const { control, handleSubmit, reset, setValue } = form;
const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => { const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
const newList = testList.map((x) => { const newList = testList.map((x) => (x.uid === uid ? { ...x, ...patch } : x));
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
await patchVerge({ test_list: newList }); await patchVerge({ test_list: newList });
}; };
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
create: () => { create: () => {
reset({ name: "", icon: "", url: "" });
setOpenType("new"); setOpenType("new");
setOpen(true); setOpen(true);
}, },
edit: (item) => { edit: (item) => {
if (item) { reset(item);
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit"); setOpenType("edit");
setOpen(true); setOpen(true);
}, },
})); }));
const handleOk = useLockFn( const handleOk = useLockFn(
formIns.handleSubmit(async (form) => { handleSubmit(async (formData) => {
setLoading(true); setLoading(true);
try { try {
if (!form.name) throw new Error("`Name` should not be null"); if (!formData.name) throw new Error("`Name` should not be null");
if (!form.url) throw new Error("`Url` should not be null"); if (!formData.url) throw new Error("`Url` should not be null");
let newList; if (formData.icon && formData.icon.startsWith("<svg")) {
let uid; // --- ИСПРАВЛЕНИЕ ЗДЕСЬ ---
// Удаляем комментарии из SVG, используя правильное регулярное выражение
formData.icon = formData.icon.replace(/<!--[\s\S]*?-->/g, "");
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
if (form.icon && form.icon.startsWith("<svg")) { const doc = new DOMParser().parseFromString(formData.icon, "image/svg+xml");
// 移除 icon 中的注释
if (form.icon) {
form.icon = form.icon.replace(/<!--[\s\S]*?-->/g, "");
}
const doc = new DOMParser().parseFromString(
form.icon,
"image/svg+xml",
);
if (doc.querySelector("parsererror")) { if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error"); throw new Error("`Icon`svg format error");
} }
} }
if (openType === "new") { if (openType === "new") {
uid = nanoid(); const uid = nanoid();
const item = { ...form, uid }; const item = { ...formData, uid };
newList = [...testList, item]; const newList = [...testList, item];
await patchVerge({ test_list: newList }); await patchVerge({ test_list: newList });
props.onChange(uid); props.onChange(uid);
} else { } else {
if (!form.uid) throw new Error("UID not found"); if (!formData.uid) throw new Error("UID not found");
uid = form.uid; await patchTestList(formData.uid, formData);
props.onChange(formData.uid, formData);
await patchTestList(uid, form);
props.onChange(uid, form);
} }
setOpen(false); setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
} catch (err: any) { } catch (err: any) {
showNotice("error", err.message || err.toString()); showNotice("error", err.message || err.toString());
} finally {
setLoading(false); setLoading(false);
} }
}), }),
); );
const handleClose = () => {
setOpen(false);
setTimeout(() => formIns.reset(), 500);
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
return ( return (
<BaseDialog <Dialog open={open} onOpenChange={setOpen}>
open={open} <DialogContent className="sm:max-w-md">
title={openType === "new" ? t("Create Test") : t("Edit Test")} <DialogHeader>
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }} <DialogTitle>{openType === "new" ? t("Create Test") : t("Edit Test")}</DialogTitle>
okBtn={t("Save")} </DialogHeader>
cancelBtn={t("Cancel")}
onClose={handleClose} <Form {...form}>
onCancel={handleClose} <form onSubmit={handleOk} className="space-y-4">
onOk={handleOk} <FormField
loading={loading} control={control}
> name="name"
<Controller rules={{ required: true }}
name="name" render={({ field }) => (
control={control} <FormItem>
render={({ field }) => ( <FormLabel>{t("Name")}</FormLabel>
<TextField {...text} {...field} label={t("Name")} /> <FormControl><Input {...field} /></FormControl>
)} <FormMessage />
/> </FormItem>
<Controller )}
name="icon" />
control={control} <FormField
render={({ field }) => ( control={control}
<TextField name="icon"
{...text} render={({ field }) => (
{...field} <FormItem>
multiline <FormLabel>{t("Icon")}</FormLabel>
maxRows={5} <FormControl><Textarea {...field} rows={4} placeholder="<svg>...</svg> or http(s)://..." /></FormControl>
label={t("Icon")} <FormMessage />
/> </FormItem>
)} )}
/> />
<Controller <FormField
name="url" control={control}
control={control} name="url"
render={({ field }) => ( rules={{ required: true }}
<TextField render={({ field }) => (
{...text} <FormItem>
{...field} <FormLabel>{t("Test URL")}</FormLabel>
multiline <FormControl><Textarea {...field} rows={3} placeholder="https://www.google.com" /></FormControl>
maxRows={3} <FormMessage />
label={t("Test URL")} </FormItem>
/> )}
)} />
/> <button type="submit" className="hidden" />
</BaseDialog> </form>
</Form>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={handleOk} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
}); });

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@root/lib/utils";
import { buttonVariants } from "@root/src/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@root/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@root/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,59 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@root/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@root/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,182 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@root/src/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className,
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,255 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

166
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,166 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@root/lib/utils";
import { Label } from "@root/src/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@root/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@root/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@root/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@root/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,183 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@root/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };

137
src/components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,137 @@
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@root/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

Some files were not shown because too many files have changed in this diff Show More