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/utilities": "^3.2.2",
"@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",
"@mui/icons-material": "^7.1.2",
"@mui/lab": "7.0.0-beta.14",
"@mui/material": "^7.1.2",
"@mui/x-data-grid": "^8.6.0",
"@tauri-apps/api": "2.6.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.3.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-global-shortcut": "^2.3.0",
"@tauri-apps/plugin-notification": "^2.3.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-shell": "2.3.0",
"@tauri-apps/plugin-updater": "2.9.0",
"@tauri-apps/plugin-window-state": "^2.3.0",
"@mui/icons-material": "^7.1.1",
"@mui/lab": "7.0.0-beta.13",
"@mui/material": "^7.1.1",
"@mui/x-data-grid": "^8.5.1",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.5",
"@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",
"ahooks": "^3.8.5",
"axios": "^1.10.0",
"chart.js": "^4.5.0",
"axios": "^1.9.0",
"chart.js": "^4.4.9",
"class-variance-authority": "^0.7.1",
"cli-color": "^2.0.4",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-shape": "^3.2.0",
"dayjs": "1.11.13",
"foxact": "^0.2.49",
"glob": "^11.0.3",
"foxact": "^0.2.45",
"glob": "^11.0.2",
"i18next": "^25.2.1",
"js-base64": "^3.7.7",
"js-yaml": "^4.1.0",
"json-schema": "^0.4.0",
"lodash-es": "^4.17.21",
"lucide-react": "^0.514.0",
"monaco-editor": "^0.52.2",
"monaco-yaml": "^5.4.0",
"nanoid": "^5.1.5",
"next-themes": "^0.4.6",
"peggy": "^5.0.3",
"react": "19.1.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0",
"react-error-boundary": "6.0.0",
"react-hook-form": "^7.58.1",
"react-i18next": "15.5.3",
"react-hook-form": "^7.57.0",
"react-i18next": "15.5.2",
"react-markdown": "10.1.0",
"react-monaco-editor": "0.58.0",
"react-router-dom": "7.6.2",
"react-virtuoso": "^4.13.0",
"react-virtuoso": "^4.12.8",
"sockette": "^2.0.6",
"sonner": "^2.0.5",
"swr": "^2.3.3",
"tailwind-merge": "^3.3.1",
"tar": "^7.4.3",
"types-pac": "^1.0.3",
"zustand": "^5.0.6"
"zod": "^3.25.67",
"zustand": "^5.0.5"
},
"devDependencies": {
"@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/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",
"@vitejs/plugin-legacy": "^7.0.0",
"@vitejs/plugin-react": "4.6.0",
"@vitejs/plugin-legacy": "^6.1.1",
"@vitejs/plugin-react": "4.5.1",
"adm-zip": "^0.5.16",
"autoprefixer": "^10.4.21",
"commander": "^14.0.0",
"cross-env": "^7.0.3",
"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",
"prettier": "^3.6.2",
"prettier-plugin-organize-imports": "^4.1.0",
"sass": "^1.89.2",
"terser": "^5.43.1",
"postcss": "^8.5.4",
"prettier": "^3.5.3",
"pretty-quick": "^4.2.2",
"sass": "^1.89.1",
"tailwindcss": "^4.1.11",
"terser": "^5.41.0",
"tw-animate-css": "^1.3.4",
"typescript": "^5.8.3",
"vite": "^7.0.0",
"vite": "^6.3.5",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-plugin-svgr": "^4.3.0"
},
"prettier": {
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"endOfLine": "lf"
},
"type": "module",
"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 Layout from "./pages/_layout";
import { useNotificationPermission } from "./hooks/useNotificationPermission";
function App() {
useNotificationPermission();
return (
<AppDataProvider>
<Layout />

View File

@@ -1,68 +1,30 @@
import React, { useSyncExternalStore } from "react";
import { Snackbar, Alert, IconButton, Box } from "@mui/material";
import { CloseRounded } from "@mui/icons-material";
"use client";
import { Toaster, toast } from "sonner";
import { useEffect, useSyncExternalStore } from "react";
import {
subscribeNotices,
hideNotice,
getSnapshotNotices,
hideNotice,
subscribeNotices,
} from "@/services/noticeService";
export const NoticeManager: React.FC = () => {
export const NoticeManager = () => {
const currentNotices = useSyncExternalStore(
subscribeNotices,
getSnapshotNotices,
);
const handleClose = (id: number) => {
hideNotice(id);
};
return (
<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>
useEffect(() => {
for (const notice of currentNotices) {
const toastId = toast(notice.message, {
id: notice.id,
duration: notice.duration,
onDismiss: (t) => {
hideNotice(t.id as number);
},
});
}
>
{notice.message}
</Alert>
</Snackbar>
))}
</Box>
);
}, [currentNotices]);
return <Toaster />;
};

View File

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

View File

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

View File

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

View File

@@ -1,38 +1,30 @@
import React from "react";
import { Box, styled } from "@mui/material";
import { cn } from "@root/lib/utils"; // Импортируем утилиту для объединения классов
type Props = {
label: string;
fontSize?: string;
width?: string;
padding?: string;
children?: React.ReactNode;
className?: string; // Пропс для дополнительной стилизации
};
export const BaseFieldset: React.FC<Props> = (props: Props) => {
const Fieldset = styled(Box)<{ component?: string }>(() => ({
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",
}));
export const BaseFieldset: React.FC<Props> = (props) => {
const { label, children, className } = props;
return (
<Fieldset component="fieldset">
<Label>{props.label}</Label>
{props.children}
</Fieldset>
// 1. Используем тег fieldset для семантики. Он позиционирован как relative.
<fieldset
className={cn(
"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 { Box, CircularProgress } from "@mui/material";
import { BaseLoading } from "./base-loading"; // 1. Импортируем наш собственный компонент загрузки
import { cn } from "@root/lib/utils";
export interface BaseLoadingOverlayProps {
isLoading: boolean;
className?: string;
}
export const BaseLoadingOverlay: React.FC<BaseLoadingOverlayProps> = ({
isLoading,
className,
}) => {
if (!isLoading) return null;
return (
<Box
sx={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(255, 255, 255, 0.7)",
zIndex: 1000,
}}
// 2. Заменяем Box на div и переводим sx в классы Tailwind
<div
className={cn(
"absolute inset-0 z-50 flex items-center justify-center bg-background/70 backdrop-blur-sm",
className
)}
>
<CircularProgress />
</Box>
{/* 3. Используем наш BaseLoading и делаем его немного больше */}
<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")`
position: relative;
display: flex;
height: 100%;
min-height: 18px;
box-sizing: border-box;
align-items: center;
interface Props {
className?: string;
}
& > div {
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 = () => {
export const BaseLoading: React.FC<Props> = ({ className }) => {
return (
<Loading>
<LoadingItem />
<LoadingItem />
<LoadingItem />
</Loading>
// 2. Используем иконку с анимацией вращения от Tailwind
// Мы можем легко менять ее размер и цвет через className
<Loader2 className={cn("h-5 w-5 animate-spin", className)} />
);
};

View File

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

View File

@@ -1,11 +1,12 @@
import { Box, SvgIcon, TextField, styled } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { ChangeEvent, useEffect, useRef, useState, useMemo } from "react";
import { ChangeEvent, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import matchCaseIcon from "@/assets/image/component/match_case.svg?react";
import matchWholeWordIcon from "@/assets/image/component/match_whole_word.svg?react";
import useRegularExpressionIcon from "@/assets/image/component/use_regular_expression.svg?react";
import { cn } from "@root/lib/utils";
// Новые импорты
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 = {
text: string;
@@ -16,150 +17,97 @@ export type SearchState = {
type SearchProps = {
placeholder?: string;
matchCase?: boolean;
matchWholeWord?: boolean;
useRegularExpression?: boolean;
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) => {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [matchCase, setMatchCase] = useState(props.matchCase ?? false);
const [matchWholeWord, setMatchWholeWord] = useState(
props.matchWholeWord ?? false,
);
const [useRegularExpression, setUseRegularExpression] = useState(
props.useRegularExpression ?? false,
);
const [text, setText] = useState("");
const [matchCase, setMatchCase] = useState(false);
const [matchWholeWord, setMatchWholeWord] = useState(false);
const [useRegularExpression, setUseRegularExpression] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const iconStyle = {
style: {
height: "24px",
width: "24px",
cursor: "pointer",
} as React.CSSProperties,
inheritViewBox: true,
};
const createMatcher = useMemo(() => {
return (searchText: string) => {
try {
setErrorMessage(""); // Сбрасываем ошибку при новой попытке
return (content: string) => {
if (!searchText) return true;
let item = !matchCase ? content.toLowerCase() : content;
let searchItem = !matchCase ? searchText.toLowerCase() : searchText;
const flags = matchCase ? "" : "i";
if (useRegularExpression) {
return new RegExp(searchItem).test(item);
return new RegExp(searchText, flags).test(content);
}
let pattern = searchText.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); // Экранируем спецсимволы
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) {
setErrorMessage(`${err}`);
return () => true;
} catch (err: any) {
setErrorMessage(err.message);
return () => true; // Возвращаем "безопасный" матчер в случае ошибки
}
};
}, [matchCase, matchWholeWord, useRegularExpression]);
useEffect(() => {
if (!inputRef.current) return;
const value = inputRef.current.value;
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]);
props.onSearch(createMatcher(text), { text, matchCase, matchWholeWord, useRegularExpression });
}, [matchCase, matchWholeWord, useRegularExpression, createMatcher]); // Убрали text из зависимостей
const onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = e.target?.value ?? "";
setErrorMessage("");
props.onSearch(createMatcher(value), {
text: value,
matchCase,
matchWholeWord,
useRegularExpression,
});
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setText(value);
props.onSearch(createMatcher(value), { text: value, matchCase, matchWholeWord, useRegularExpression });
};
const getToggleVariant = (isActive: boolean) => (isActive ? "secondary" : "ghost");
return (
<Tooltip title={errorMessage} placement="bottom-start">
<StyledTextField
autoComplete="new-password"
inputRef={inputRef}
hiddenLabel
fullWidth
size="small"
variant="outlined"
spellCheck="false"
<div className="w-full">
<div className="relative">
{/* Добавляем правый отступ, чтобы текст не заезжал под иконки */}
<Input
placeholder={props.placeholder ?? t("Filter conditions")}
sx={{ input: { py: 0.65, px: 1.25 } }}
onChange={onChange}
slotProps={{
input: {
sx: { pr: 1 },
endAdornment: (
<Box display="flex">
<Tooltip title={t("Match Case")}>
<div>
<SvgIcon
component={matchCaseIcon}
{...iconStyle}
aria-label={matchCase ? "active" : "inactive"}
onClick={() => setMatchCase(!matchCase)}
value={text}
onChange={handleChange}
className="pr-28" // pr-[112px]
/>
{/* Контейнер для иконок, абсолютно спозиционированный справа */}
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(matchCase)} size="icon" className="h-7 w-7" onClick={() => setMatchCase(!matchCase)}>
<CaseSensitive className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Match Case")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(matchWholeWord)} size="icon" className="h-7 w-7" onClick={() => setMatchWholeWord(!matchWholeWord)}>
<WholeWord className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Match Whole Word")}</p></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={getToggleVariant(useRegularExpression)} size="icon" className="h-7 w-7" onClick={() => setUseRegularExpression(!useRegularExpression)}>
<Regex className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Use Regular Expression")}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</Tooltip>
<Tooltip title={t("Match Whole Word")}>
<div>
<SvgIcon
component={matchWholeWordIcon}
{...iconStyle}
aria-label={matchWholeWord ? "active" : "inactive"}
onClick={() => setMatchWholeWord(!matchWholeWord)}
/>
</div>
</Tooltip>
<Tooltip title={t("Use Regular Expression")}>
<div>
<SvgIcon
component={useRegularExpressionIcon}
aria-label={useRegularExpression ? "active" : "inactive"}
{...iconStyle}
onClick={() =>
setUseRegularExpression(!useRegularExpression)
}
/>{" "}
{/* Отображение ошибки под полем ввода */}
{errorMessage && <p className="mt-1 text-xs text-destructive">{errorMessage}</p>}
</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 (
<Select
size="small"
autoComplete="new-password"
sx={{
width: 120,
height: 33.375,
mr: 1,
'[role="button"]': { py: 0.65 },
}}
{...props}
/>
// Используем композицию компонентов Select из shadcn/ui
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger
className={cn(
"h-9 w-[180px]", // Задаем стандартные размеры, как у других селектов
className
)}
>
<SelectValue placeholder={placeholder} />
</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 { 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 { className, ...restProps } = props;
return (
<TextField
autoComplete="new-password"
hiddenLabel
fullWidth
size="small"
variant="outlined"
<Input
ref={ref}
className={cn(
"h-9", // Задаем стандартную компактную высоту
className
)}
placeholder={props.placeholder ?? t("Filter conditions")}
autoComplete="off"
spellCheck="false"
placeholder={t("Filter conditions")}
sx={{ input: { py: 0.65, px: 1.25 } }}
{...props}
{...restProps}
/>
);
})(({ 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 { default as MuiSwitch, SwitchProps } from "@mui/material/Switch";
import * as React from "react";
import { Switch as ShadcnSwitch } from "@/components/ui/switch";
import { cn } from "@root/lib/utils";
export const Switch = styled((props: SwitchProps) => (
<MuiSwitch
focusVisibleClassName=".Mui-focusVisible"
disableRipple
// Тип пропсов остается без изменений
export type SwitchProps = React.ComponentPropsWithoutRef<typeof ShadcnSwitch>;
const Switch = React.forwardRef<
HTMLButtonElement,
SwitchProps
>(({ className, ...props }, ref) => {
return (
<ShadcnSwitch
className={cn(className)}
ref={ref}
{...props}
/>
))(({ theme }) => ({
width: 42,
height: 26,
padding: 0,
marginRight: 1,
"& .MuiSwitch-switchBase": {
padding: 0,
margin: 2,
transitionDuration: "300ms",
"&.Mui-checked": {
transform: "translateX(16px)",
color: "#fff",
"& + .MuiSwitch-track": {
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,
}),
},
}));
);
});
Switch.displayName = "Switch";
export { Switch };

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 {
Tooltip,
IconButton,
IconButtonProps,
SvgIconProps,
} from "@mui/material";
import { InfoRounded } from "@mui/icons-material";
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Info } from "lucide-react";
interface Props extends IconButtonProps {
title?: string;
icon?: React.ElementType<SvgIconProps>;
// 2. Определяем наши пропсы, расширяя стандартный тип для кнопок из React
export interface TooltipIconProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
tooltip: React.ReactNode;
icon?: React.ReactNode;
}
export const TooltipIcon: React.FC<Props> = (props: Props) => {
const { title = "", icon: Icon = InfoRounded, ...restProps } = props;
export const TooltipIcon = React.forwardRef<
HTMLButtonElement,
TooltipIconProps
>(({ tooltip, icon, className, ...props }, ref) => {
const displayIcon = icon || <Info className="h-4 w-4" />;
return (
<Tooltip title={title} placement="top">
<IconButton color="inherit" size="small" {...restProps}>
<Icon fontSize="inherit" style={{ cursor: "pointer", opacity: 0.75 }} />
</IconButton>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
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 { forwardRef, useImperativeHandle, useState } from "react";
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 parseTraffic from "@/utils/parse-traffic";
import { t } from "i18next";
import { Button } from "@/components/ui/button";
export interface ConnectionDetailRef {
open: (detail: IConnectionsItem) => void;
@@ -14,38 +20,37 @@ export const ConnectionDetail = forwardRef<ConnectionDetailRef>(
(props, ref) => {
const [open, setOpen] = useState(false);
const [detail, setDetail] = useState<IConnectionsItem>(null!);
const theme = useTheme();
useImperativeHandle(ref, () => ({
open: (detail: IConnectionsItem) => {
if (open) return;
setOpen(true);
setDetail(detail);
setOpen(true);
},
}));
const onClose = () => setOpen(false);
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
};
if (!detail) return null;
return (
<Snackbar
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
open={open}
onClose={onClose}
sx={{
".MuiSnackbarContent-root": {
maxWidth: "520px",
maxHeight: "480px",
overflowY: "auto",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
},
}}
message={
detail ? (
<InnerConnectionDetail data={detail} onClose={onClose} />
) : null
}
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="right"
className="w-full max-w-[520px] max-h-[100vh] sm:max-h-[calc(100vh-2rem)] overflow-y-auto p-0 flex flex-col"
>
<SheetHeader className="p-6 pb-4">
<SheetTitle>{t("Connection Details")}</SheetTitle>
</SheetHeader>
<div className="flex-grow overflow-y-auto p-6 pt-0">
<InnerConnectionDetail
data={detail}
onClose={() => setOpen(false)}
/>
</div>
</SheetContent>
</Sheet>
);
},
);
@@ -57,7 +62,6 @@ interface InnerProps {
const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const { metadata, rulePayload } = data;
const theme = useTheme();
const chains = [...data.chains].reverse().join(" / ");
const rule = rulePayload ? `${data.rule}(${rulePayload})` : data.rule;
const host = metadata.host
@@ -86,7 +90,9 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
{ label: t("Rule"), value: rule },
{
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() },
{
@@ -101,24 +107,16 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
const onDelete = useLockFn(async () => deleteConnection(data.id));
return (
<Box sx={{ userSelect: "text", color: theme.palette.text.secondary }}>
<div className="select-text text-muted-foreground">
{information.map((each) => (
<div key={each.label}>
<b>{each.label}</b>
<span
style={{
wordBreak: "break-all",
color: theme.palette.text.primary,
}}
>
: {each.value}
</span>
<div key={each.label} className="mb-1">
<b className="text-foreground">{each.label}</b>
<span className="break-all text-foreground">: {each.value}</span>
</div>
))}
<Box sx={{ textAlign: "right" }}>
<div className="text-right mt-4">
<Button
variant="contained"
title={t("Close Connection")}
onClick={() => {
onDelete();
@@ -127,7 +125,7 @@ const InnerConnectionDetail = ({ data, onClose }: InnerProps) => {
>
{t("Close Connection")}
</Button>
</Box>
</Box>
</div>
</div>
);
};

View File

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

View File

@@ -1,139 +1,73 @@
import dayjs from "dayjs";
import { useMemo, useState, useEffect } from "react";
import { DataGrid, GridColDef, GridColumnResizeParams } from "@mui/x-data-grid";
import { useThemeMode } from "@/services/states";
import relativeTime from "dayjs/plugin/relativeTime";
import React, { useMemo, useState, useEffect, RefObject } from "react";
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 parseTraffic from "@/utils/parse-traffic";
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 {
connections: IConnectionsItem[];
onShowDetail: (data: IConnectionsItem) => void;
scrollerRef: (element: HTMLElement | Window | null) => void;
}
export const ConnectionTable = (props: Props) => {
const { connections, onShowDetail } = props;
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
const backgroundColor = isDark ? "#282A36" : "#ffffff";
const { connections, onShowDetail, scrollerRef } = props;
const [columnVisible, setColumnVisible] = useState<
Partial<Record<keyof IConnectionsItem, boolean>>
>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(
() => {
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
try {
const saved = localStorage.getItem("connection-table-widths");
return saved ? JSON.parse(saved) : {};
},
);
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,
},
]);
} catch { return {}; }
});
useEffect(() => {
console.log("Saving column widths:", columnWidths);
localStorage.setItem(
"connection-table-widths",
JSON.stringify(columnWidths),
);
}, [columnWidths]);
localStorage.setItem("connection-table-widths", JSON.stringify(columnSizing));
}, [columnSizing]);
const handleColumnResize = (params: GridColumnResizeParams) => {
const { colDef, width } = params;
console.log("Column resize:", colDef.field, width);
setColumnWidths((prev) => ({
...prev,
[colDef.field]: width,
}));
};
const connRows = useMemo(() => {
const connRows = useMemo((): ConnectionRow[] => {
return connections.map((each) => {
const { metadata, rulePayload } = each;
const chains = [...each.chains].reverse().join(" / ");
@@ -148,11 +82,11 @@ export const ConnectionTable = (props: Props) => {
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
download: each.download,
upload: each.upload,
dlSpeed: each.curDownload,
ulSpeed: each.curUpload,
dlSpeed: each.curDownload ?? 0,
ulSpeed: each.curUpload ?? 0,
chains,
rule,
process: truncateStr(metadata.process || metadata.processPath),
process: truncateStr(metadata.process || metadata.processPath) ?? '',
time: each.start,
source: `${metadata.sourceIP}:${metadata.sourcePort}`,
remoteDestination: Destination,
@@ -162,24 +96,97 @@ export const ConnectionTable = (props: Props) => {
});
}, [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 (
<DataGrid
hideFooter
rows={connRows}
columns={columns}
onRowClick={(e) => onShowDetail(e.row.connectionData)}
density="compact"
sx={{
border: "none",
"div:focus": { outline: "none !important" },
"& .MuiDataGrid-columnHeader": {
userSelect: "none",
},
}}
columnVisibilityModel={columnVisible}
onColumnVisibilityModelChange={(e) => setColumnVisible(e)}
onColumnResize={handleColumnResize}
disableColumnMenu={false}
<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 (
<div className="h-full rounded-md border overflow-hidden">
{connRows.length > 0 ? (
<TableVirtuoso
scrollerRef={scrollerRef}
data={table.getRowModel().rows}
components={VirtuosoTableComponents}
fixedHeaderContent={() => (
table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent bg-background/95 backdrop-blur">
{headerGroup.headers.map((header) => (
<TableHead key={header.id} style={{ width: header.getSize() }} className="p-2">
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
))
)}
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 {
alpha,
ListItem,
ListItemButton,
ListItemText,
ListItemIcon,
} from "@mui/material";
import { useMatch, useResolvedPath, useNavigate } from "react-router-dom";
import { Link, useMatch, useResolvedPath } from "react-router-dom";
import { useVerge } from "@/hooks/use-verge";
import { cn } from "@root/lib/utils";
interface Props {
to: string;
children: string;
icon: React.ReactNode[];
}
export const LayoutItem = (props: Props) => {
const { to, children, icon } = props;
const { verge } = useVerge();
const { menu_icon } = verge ?? {};
const resolved = useResolvedPath(to);
const match = useMatch({ path: resolved.pathname, end: true });
const navigate = useNavigate();
return (
<ListItem sx={{ py: 0.5, maxWidth: 250, mx: "auto", padding: "4px 0px" }}>
<ListItemButton
selected={!!match}
sx={[
{
borderRadius: 2,
marginLeft: 1.25,
paddingLeft: 1,
paddingRight: 1,
marginRight: 1.25,
"& .MuiListItemText-primary": {
color: "text.primary",
fontWeight: "700",
},
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? 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)}
<Link
to={to}
className={cn(
"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
? "bg-primary text-primary-foreground shadow-md"
: "hover:bg-muted/50",
"mx-auto my-1 w-[calc(100%-10px)]",
)}
>
{(menu_icon === "monochrome" || !menu_icon) && (
<ListItemIcon sx={{ color: "text.primary", marginLeft: "6px" }}>
{icon[0]}
</ListItemIcon>
<span className="mr-2 text-foreground">{icon[0]}</span>
)}
{menu_icon === "colorful" && <ListItemIcon>{icon[1]}</ListItemIcon>}
<ListItemText
sx={{
textAlign: "center",
marginLeft: menu_icon === "disable" ? "" : "-35px",
}}
primary={children}
/>
</ListItemButton>
</ListItem>
{menu_icon === "colorful" && <span className="mr-2">{icon[1]}</span>}
<span
className={cn(
"text-center",
menu_icon === "disable" ? "" : "ml-[-35px]",
)}
>
{children}
</span>
</Link>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,21 @@
import { useVerge } from "@/hooks/use-verge";
import { defaultDarkTheme, defaultTheme } from "@/pages/_theme";
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 { useEffect, useMemo } from "react";
import { alpha, createTheme, Shadows, Theme as MuiTheme } from "@mui/material";
import {
getCurrentWebviewWindow,
WebviewWindow,
} from "@tauri-apps/api/webviewWindow";
import { Theme as TauriOsTheme } from "@tauri-apps/api/window";
import { useEffect, useMemo } from "react";
import { useSetThemeMode, useThemeMode } from "@/services/states";
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 { Theme as TauriOsTheme } from "@tauri-apps/api/window";
const languagePackMap: Record<string, any> = {
zh: { ...zhXDataGrid },
@@ -39,10 +39,6 @@ export const useCustomTheme = () => {
const mode = useThemeMode();
const setMode = useSetThemeMode();
// 提取用户自定义的背景图URL
const userBackgroundImage = theme_setting?.background_image || "";
const hasUserBackground = !!userBackgroundImage;
useEffect(() => {
if (theme_mode === "light" || theme_mode === "dark") {
setMode(theme_mode);
@@ -56,6 +52,8 @@ export const useCustomTheme = () => {
let isMounted = true;
const timerId = setTimeout(() => {
if (!isMounted) return;
appWindow
.theme()
.then((systemTheme) => {
@@ -66,6 +64,7 @@ export const useCustomTheme = () => {
.catch((err) => {
console.error("Failed to get initial system theme:", err);
});
}, 0);
const unlistenPromise = appWindow.onThemeChanged(({ payload }) => {
if (isMounted) {
@@ -75,6 +74,7 @@ export const useCustomTheme = () => {
return () => {
isMounted = false;
clearTimeout(timerId);
unlistenPromise
.then((unlistenFn) => {
if (typeof unlistenFn === "function") {
@@ -131,7 +131,6 @@ export const useCustomTheme = () => {
},
background: {
paper: dt.background_color,
default: dt.background_color,
},
},
shadows: Array(25).fill("none") as Shadows,
@@ -158,10 +157,6 @@ export const useCustomTheme = () => {
warning: { main: dt.warning_color },
success: { main: dt.success_color },
text: { primary: dt.primary_text, secondary: dt.secondary_text },
background: {
paper: dt.background_color,
default: dt.background_color,
},
},
typography: { fontFamily: dt.font_family },
});
@@ -169,10 +164,9 @@ export const useCustomTheme = () => {
const rootEle = document.documentElement;
if (rootEle) {
const backgroundColor =
mode === "light" ? "#ECECEC" : dt.background_color;
const selectColor = mode === "light" ? "#f5f5f5" : "#3E3E3E";
const scrollColor = mode === "light" ? "#90939980" : "#555555";
const backgroundColor = mode === "light" ? "#ECECEC" : "#2e303d";
const selectColor = mode === "light" ? "#f5f5f5" : "#d5d5d5";
const scrollColor = mode === "light" ? "#90939980" : "#3E3E3Eee";
const dividerColor =
mode === "light" ? "rgba(0, 0, 0, 0.06)" : "rgba(255, 255, 255, 0.06)";
@@ -188,96 +182,16 @@ export const useCustomTheme = () => {
"--background-color-alpha",
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");
if (!styleElement) {
styleElement = document.createElement("style");
styleElement.id = "verge-theme";
document.head.appendChild(styleElement!);
}
if (styleElement) {
// 改进的全局样式,支持用户自定义背景图
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;
styleElement.innerHTML = setting.css_injection || "";
}
const { palette } = muiTheme;
@@ -293,13 +207,7 @@ export const useCustomTheme = () => {
}, 0);
return muiTheme;
}, [
mode,
theme_setting,
i18n.language,
userBackgroundImage,
hasUserBackground,
]);
}, [mode, theme_setting, i18n.language]);
return { theme };
};

View File

@@ -1,45 +1,5 @@
import { styled, Box } from "@mui/material";
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 {
value: ILogItem;
searchState?: SearchState;
@@ -70,7 +30,10 @@ const LogItem = ({ value, searchState }: Props) => {
return parts.map((part, index) => {
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}
</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 (
<Item>
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 text-sm font-mono select-text">
<div>
<span className="time">{renderHighlightText(value.time || "")}</span>
<span className="type" data-type={value.type.toLowerCase()}>
<span className="text-gray-500 dark:text-gray-400 mr-2">
{renderHighlightText(value.time || "")}
</span>
<span
className={`inline-block ml-2 text-center rounded uppercase font-semibold ${typeColorClass}`}
data-type={lowerCaseType}
>
{renderHighlightText(value.type)}
</span>
</div>
<div>
<span className="data">{renderHighlightText(value.payload)}</span>
<div className="text-gray-800 dark:text-gray-200 break-all whitespace-pre-wrap">
{renderHighlightText(value.payload)}
</div>
</div>
</Item>
);
};

View File

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

View File

@@ -1,20 +1,6 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { useLockFn } from "ahooks";
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 { nanoid } from "nanoid";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
@@ -22,6 +8,7 @@ import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import debounce from "@/utils/debounce";
// --- Новые импорты ---
import * as monaco from "monaco-editor";
import MonacoEditor from "react-monaco-editor";
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 mergeSchema from "meta-json-schema/schemas/clash-verge-merge-json-schema.json";
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();
// --- Типы и интерфейсы (без изменений) ---
type Language = "yaml" | "javascript" | "css";
type Schema<T extends Language> = LanguageSchemaMap[T];
interface LanguageSchemaMap {
@@ -51,11 +56,11 @@ interface Props<T extends Language> {
onClose: () => void;
}
// --- Логика инициализации Monaco (без изменений) ---
let initialized = false;
const monacoInitialization = () => {
if (initialized) return;
// configure yaml worker
configureMonacoYaml(monaco, {
validate: true,
enableSchemaRequest: true,
@@ -74,7 +79,6 @@ const monacoInitialization = () => {
},
],
});
// configure PAC definition
monaco.languages.typescript.javascriptDefaults.addExtraLib(pac, "pac.d.ts");
initialized = true;
@@ -170,85 +174,97 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
}, []);
return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>{title}</DialogTitle>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent
sx={{
width: "auto",
height: "calc(100vh - 185px)",
overflow: "hidden",
}}
className="h-[95vh] flex flex-col p-0"
style={{ width: "95vw", maxWidth: "95vw" }}
>
<DialogHeader className="p-6 pb-2">
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0 relative px-6">
<MonacoEditor
height="100%"
language={language}
theme={themeMode === "light" ? "vs" : "vs-dark"}
options={{
tabSize: ["yaml", "javascript", "css"].includes(language) ? 2 : 4, // 根据语言类型设置缩进大小
tabSize: 2,
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
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true,
readOnly: readOnly,
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"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
fontLigatures: false,
smoothScrolling: true,
}}
editorWillMount={editorWillMount}
editorDidMount={editorDidMount}
onChange={handleChange}
/>
<ButtonGroup
variant="contained"
sx={{ position: "absolute", left: "14px", bottom: "8px" }}
>
<IconButton
size="medium"
color="inherit"
sx={{ display: readOnly ? "none" : "" }}
title={t("Format document")}
<div className="absolute bottom-4 left-8 z-10 flex gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
disabled={readOnly}
onClick={() =>
editorRef.current
?.getAction("editor.action.formatDocument")
?.run()
}
>
<FormatPaintRounded fontSize="inherit" />
</IconButton>
<IconButton
size="medium"
color="inherit"
title={t(isMaximized ? "Minimize" : "Maximize")}
onClick={() => appWindow.toggleMaximize().then(editorResize)}
<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 ? <CloseFullscreenRounded /> : <OpenInFullRounded />}
</IconButton>
</ButtonGroup>
</DialogContent>
{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>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
<DialogFooter className="p-6 pt-4">
<DialogClose asChild>
<Button type="button" variant="outline">
{t(readOnly ? "Close" : "Cancel")}
</Button>
</DialogClose>
{!readOnly && (
<Button onClick={handleSave} variant="contained">
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
)}
</DialogActions>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

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

View File

@@ -1,26 +1,34 @@
import {
Box,
IconButton,
ListItem,
ListItemText,
alpha,
styled,
} from "@mui/material";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useEffect, useState } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { downloadIconCache } from "@/services/cmds";
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 {
type: "prepend" | "original" | "delete" | "append";
group: IProxyGroupConfig;
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) => {
let { type, group, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { type, group, onDelete } = props;
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
const {
attributes,
@@ -29,145 +37,73 @@ export const GroupItem = (props: Props) => {
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: group.name })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
} = useSortable({ id: group.name, disabled: !isSortable });
const [iconCachePath, setIconCachePath] = useState("");
useEffect(() => {
initIconCachePath();
}, [group]);
const getFileName = (url: string) => url.substring(url.lastIndexOf("/") + 1);
async function initIconCachePath() {
if (group.icon && group.icon.trim().startsWith("http")) {
const fileName =
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
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);
}
useEffect(() => { initIconCachePath(); }, [group.icon, group.name]);
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: 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",
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
zIndex: isDragging ? 100 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
// Применяем стили в зависимости от типа
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
)}
>
{group.icon && group.icon?.trim().startsWith("http") && (
<img
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
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
ref={setNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
>
{group.name}
</StyledPrimary>
}
secondary={
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
}}
>
<Box sx={{ marginTop: "2px" }}>
<StyledTypeBox>{group.type}</StyledTypeBox>
</Box>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
<GripVertical className="h-5 w-5" />
</div>
{/* Иконка группы */}
{group.icon && (
<img
src={group.icon.startsWith('data') ? group.icon : group.icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(group.icon ?? "")}` : (iconCachePath || group.icon)}
className="w-8 h-8 mx-2 rounded-md"
alt={group.name}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
)}
{/* Название и тип группы */}
<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">
<Badge variant="outline">{group.type}</Badge>
</div>
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
);
};
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 { useTranslation } from "react-i18next";
// Новые импорты
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogHeader,
DialogTitle,
Divider,
Typography,
} from "@mui/material";
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { BaseEmpty } from "@/components/base";
import { cn } from "@root/lib/utils";
interface Props {
open: boolean;
@@ -20,50 +23,47 @@ interface Props {
export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { t } = useTranslation();
// Вспомогательная функция для определения варианта Badge
const getLogLevelVariant = (level: string): "destructive" | "secondary" => {
return level === "error" || level === "exception" ? "destructive" : "secondary";
};
return (
<Dialog open={open} onClose={onClose}>
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("Script Console")}</DialogTitle>
</DialogHeader>
<DialogContent
sx={{
width: 400,
height: 300,
overflowX: "hidden",
userSelect: "text",
pb: 1,
}}
>
{logInfo.map(([level, log], index) => (
<Fragment key={index.toString()}>
<Typography color="text.secondary" component="div">
<Chip
label={level}
size="small"
variant="outlined"
color={
level === "error" || level === "exception"
? "error"
: "default"
}
sx={{ mr: 1 }}
/>
{/* Контейнер для логов с прокруткой */}
<div className="h-[300px] overflow-y-auto space-y-2 p-1">
{logInfo.length > 0 ? (
logInfo.map(([level, log], index) => (
<div key={index} className="pb-2 border-b border-border last:border-b-0">
<div className="flex items-start gap-3">
<Badge variant={getLogLevelVariant(level)} className="mt-0.5">
{level}
</Badge>
{/* `whitespace-pre-wrap` сохраняет переносы строк и пробелы в логах */}
<p className="flex-1 text-sm whitespace-pre-wrap break-words font-mono">
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
</p>
</div>
</div>
))
) : (
<BaseEmpty />
)}
</div>
{logInfo.length === 0 && <BaseEmpty />}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
</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)(({
theme,
"aria-selected": selected,
}) => {
const { mode, primary, text } = theme.palette;
const key = `${mode}-${!!selected}`;
// Определяем пропсы: принимает все атрибуты для div и булевый пропс `selected`
export interface ProfileBoxProps extends React.HTMLAttributes<HTMLDivElement> {
selected?: boolean;
}
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,
"dark-true": alpha(text.secondary, 0.65),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
// --- Эффект рамки ---
// По умолчанию рамка есть, но она прозрачная, чтобы резервировать место
"border-l-4 border-transparent",
// При выборе (`data-selected=true`) рамка окрашивается в основной цвет
"data-[selected=true]:border-primary",
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.main,
"dark-false": text.primary,
}[key]!;
// --- Эффект смены цвета текста ---
// При выборе весь текст внутри становится более контрастным
"data-[selected=true]:text-card-foreground",
const borderSelect = {
"light-true": {
borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
},
"light-false": {
width: "100%",
},
"dark-true": {
borderLeft: `3px solid ${primary.main}`,
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
},
"dark-false": {
width: "100%",
},
}[key];
// --- Дополнительные классы от пользователя ---
className
)}
{...props}
>
{children}
</div>
);
}
);
return {
position: "relative",
display: "block",
cursor: "pointer",
textAlign: "left",
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
...borderSelect,
borderRadius: "8px",
color,
"& h2": { color: h2color },
};
});
ProfileBox.displayName = "ProfileBox";

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,24 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import {
Box,
Badge,
Chip,
Typography,
MenuItem,
Menu,
IconButton,
} from "@mui/material";
import { FeaturedPlayListRounded } from "@mui/icons-material";
import { UnlistenFn } from "@tauri-apps/api/event";
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 { 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 {
logInfo?: [string, string][];
@@ -23,23 +26,18 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void;
}
// profile enhanced item
export const ProfileMore = (props: Props) => {
const { id, logInfo = [], onSave } = props;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false);
const onEditFile = () => {
setAnchorEl(null);
setFileOpen(true);
};
const onOpenFile = useLockFn(async () => {
setAnchorEl(null);
try {
await viewProfile(id);
} 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 itemMenu = [
{ label: "Edit File", handler: onEditFile },
{ label: "Open File", handler: onOpenFile },
const menuItems = [
{ label: "Edit File", handler: onEditFile, icon: FileText },
{ label: "Open File", handler: onOpenFile, icon: FolderOpen },
];
const boxStyle = {
height: 26,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
lineHeight: 1,
};
return (
<>
<ProfileBox
onDoubleClick={onEditFile}
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
>
<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>
<ContextMenu>
<ContextMenuTrigger>
{/* Используем наш готовый ProfileBox */}
<ProfileBox onDoubleClick={onEditFile}>
{/* Верхняя строка: Название и Бейдж */}
<div className="flex justify-between items-center mb-2">
<p className="font-semibold text-base truncate">{t(`Global ${id}`)}</p>
<Badge variant="secondary">{id}</Badge>
</div>
<Chip
label={id}
color="primary"
size="small"
variant="outlined"
sx={{ height: 20, textTransform: "capitalize" }}
/>
</Box>
<Box sx={boxStyle}>
{id === "Script" &&
(hasError ? (
<Badge color="error" variant="dot" overlap="circular">
<IconButton
size="small"
edge="start"
color="error"
title={t("Script Console")}
{/* Нижняя строка: Кнопка логов или заглушка для сохранения высоты */}
<div className="h-7 flex items-center">
{id === "Script" && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Контейнер для позиционирования точки-индикатора */}
<div className="relative">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
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>
<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>
<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>
{/* Содержимое контекстного меню */}
<ContextMenuContent>
{menuItems.map((item) => (
<ContextMenuItem key={item.label} onSelect={item.handler}>
<item.icon className="mr-2 h-4 w-4" />
<span>{t(item.label)}</span>
</ContextMenuItem>
))}
</Menu>
</ContextMenuContent>
</ContextMenu>
{/* Модальные окна, которые мы уже переделали */}
{fileOpen && (
<EditorViewer
open={true}
@@ -176,7 +120,7 @@ export const ProfileMore = (props: Props) => {
schema={id === "Merge" ? "clash" : undefined}
onSave={async (prev, curr) => {
await saveProfileFile(id, curr ?? "");
onSave && onSave(prev, curr);
onSave?.(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>

View File

@@ -1,29 +1,42 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import {
Box,
FormControl,
InputAdornment,
InputLabel,
MenuItem,
Select,
styled,
TextField,
} from "@mui/material";
import { useForm } from "react-hook-form";
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 { 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 {
onChange: (isActivating?: boolean) => void;
@@ -34,20 +47,15 @@ export interface ProfileViewerRef {
edit: (item: IProfileItem) => void;
}
// create or edit the profile
// remote / local
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
(props, ref) => {
export const ProfileViewer = forwardRef<ProfileViewerRef, Props>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [openType, setOpenType] = useState<"new" | "edit">("new");
const [loading, setLoading] = useState(false);
const { profiles } = useProfiles();
// file input
const fileDataRef = useRef<string | null>(null);
const { control, watch, register, ...formIns } = useForm<IProfileItem>({
const form = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
@@ -56,21 +64,23 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
},
});
const { control, watch, handleSubmit, reset, setValue } = form;
useImperativeHandle(ref, () => ({
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) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
reset(item);
fileDataRef.current = null;
setOpenType("edit");
setOpen(true);
},
@@ -80,28 +90,26 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (selfProxy) formIns.setValue("option.with_proxy", false);
}, [selfProxy]);
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
useEffect(() => {
if (withProxy) formIns.setValue("option.self_proxy", false);
}, [withProxy]);
if (withProxy) setValue("option.self_proxy", false);
}, [withProxy, setValue]);
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
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");
}
// 处理表单数据
if (form.option?.update_interval) {
form.option.update_interval = +form.option.update_interval;
} else {
@@ -115,18 +123,9 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
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 };
// 判断是否是当前激活的配置
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);
@@ -135,9 +134,7 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
await patchProfile(form.uid, item);
}
} else {
// 远程配置使用回退机制
try {
// 尝试正常操作
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
@@ -145,49 +142,21 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
await patchProfile(form.uid, item);
}
} catch (err) {
// 首次创建/更新失败,尝试使用自身代理
showNotice(
"info",
t("Profile creation failed, retrying with Clash proxy..."),
);
// 使用自身代理的配置
const retryItem = {
...item,
option: {
...item.option,
with_proxy: false,
self_proxy: true,
},
};
// 使用自身代理再次尝试
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 {
@@ -196,199 +165,135 @@ export const ProfileViewer = forwardRef<ProfileViewerRef, Props>(
}),
);
const handleClose = () => {
try {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
} catch {}
};
const text = {
fullWidth: true,
size: "small",
margin: "normal",
variant: "outlined",
autoComplete: "off",
autoCorrect: "off",
} as const;
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
return (
<BaseDialog
open={open}
title={openType === "new" ? t("Create Profile") : t("Edit Profile")}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
name="type"
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>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={e => { e.preventDefault(); handleOk(); }} className="space-y-4 max-h-[70vh] overflow-y-auto px-1">
<FormField control={control} name="type" render={({ field }) => (
<FormItem>
<FormLabel>{t("Type")}</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl><SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger></FormControl>
<SelectContent>
<SelectItem value="remote">Remote</SelectItem>
<SelectItem value="local">Local</SelectItem>
</SelectContent>
</Select>
</FormControl>
)}
/>
<FormMessage />
</FormItem>
)}/>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Name")} />
)}
/>
<FormField control={control} name="name" render={({ field }) => (
<FormItem>
<FormLabel>{t("Name")}</FormLabel>
<FormControl><Input placeholder={t("Profile Name")} {...field} /></FormControl>
<FormMessage />
</FormItem>
)}/>
<Controller
name="desc"
control={control}
render={({ field }) => (
<TextField {...text} {...field} label={t("Descriptions")} />
)}
/>
<FormField control={control} name="desc" render={({ field }) => (
<FormItem>
<FormLabel>{t("Descriptions")}</FormLabel>
<FormControl><Input placeholder={t("Profile Description")} {...field} /></FormControl>
<FormMessage />
</FormItem>
)}/>
{isRemote && (
<>
<Controller
name="url"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
label={t("Subscription URL")}
/>
)}
/>
<Controller
name="option.user_agent"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
placeholder={`clash-verge/v${version}`}
label="User Agent"
/>
)}
/>
<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>
),
},
}}
/>
)}
/>
<FormField control={control} name="url" render={({ field }) => (
<FormItem>
<FormLabel>{t("Subscription URL")}</FormLabel>
<FormControl><Textarea placeholder="https://example.com/profile.yaml" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}/>
<FormField control={control} name="option.user_agent" render={({ field }) => (
<FormItem>
<FormLabel>User Agent</FormLabel>
<FormControl><Input placeholder={`clash-verge/v${version}`} {...field} /></FormControl>
<FormMessage />
</FormItem>
)}/>
<FormField control={control} name="option.update_interval" render={({ field }) => (
<FormItem>
<FormLabel>{t("Update Interval")}</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
<Input type="number" placeholder="1440" {...field} onChange={event => field.onChange(parseInt(event.target.value, 10) || 0)} />
<span className="text-sm text-muted-foreground">{t("mins")}</span>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}/>
</>
)}
{(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" && (
<FileInput
onChange={(file, val) => {
formIns.setValue("name", formIns.getValues("name") || file.name);
fileDataRef.current = val;
}}
/>
<FormItem>
<FormLabel>{t("File")}</FormLabel>
<FormControl>
<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 && (
<>
<Controller
name="option.with_proxy"
control={control}
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Use System Proxy")}</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
<div className="space-y-4 rounded-md border p-4">
<FormField control={control} name="option.with_proxy" render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between">
<FormLabel>{t("Use System Proxy")}</FormLabel>
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
)}/>
<FormField control={control} name="option.self_proxy" render={({ field }) => (
<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
name="option.self_proxy"
control={control}
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Use Clash Proxy")}</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
)}
/>
<button type="submit" className="hidden" />
</form>
</Form>
<Controller
name="option.danger_accept_invalid_certs"
control={control}
render={({ field }) => (
<StyledBox>
<InputLabel>{t("Accept Invalid Certs (Danger)")}</InputLabel>
<Switch checked={field.value} {...field} color="primary" />
</StyledBox>
)}
/>
</>
)}
</BaseDialog>
<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>
);
},
);
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 yaml from "js-yaml";
import { useTranslation } from "react-i18next";
@@ -14,33 +14,41 @@ import {
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import {
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 { CSS } from "@dnd-kit/utilities";
import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { useThemeMode } from "@/services/states";
import parseUri from "@/utils/uri-parser";
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 {
profileUid: string;
property: string;
@@ -49,6 +57,69 @@ interface Props {
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) => {
const { profileUid, property, open, onClose, onSave } = props;
const { t } = useTranslation();
@@ -83,6 +154,7 @@ export const ProxiesEditorViewer = (props: Props) => {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const reorder = (
list: IProxyConfig[],
startIndex: number,
@@ -93,44 +165,33 @@ export const ProxiesEditorViewer = (props: Props) => {
result.splice(endIndex, 0, removed);
return result;
};
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
if (over && active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
}
if (item.name === over.id) {
overIndex = index;
}
if (item.name === active.id) activeIndex = index;
if (item.name === over.id) overIndex = index;
});
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
}
};
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
if (over && active.id !== over.id) {
let activeIndex = 0;
let overIndex = 0;
appendSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
}
if (item.name === over.id) {
overIndex = index;
}
if (item.name === active.id) activeIndex = index;
if (item.name === over.id) overIndex = index;
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
}
};
// 优化异步分片解析避免主线程阻塞解析完成后批量setState
const handleParseAsync = (cb: (proxies: IProxyConfig[]) => void) => {
let proxies: IProxyConfig[] = [];
let names: string[] = [];
@@ -154,7 +215,7 @@ export const ProxiesEditorViewer = (props: Props) => {
names.push(proxy.name);
}
} catch (err: any) {
// 不阻塞主流程
// Ignore parse errors
}
}
if (idx < lines.length) {
@@ -165,32 +226,28 @@ export const ProxiesEditorViewer = (props: Props) => {
}
parseBatch();
};
const fetchProfile = async () => {
let data = await readProfileFile(profileUid);
let originProxiesObj = yaml.load(data) as {
proxies: IProxyConfig[];
} | null;
setProxyList(originProxiesObj?.proxies || []);
};
const fetchContent = async () => {
let data = await readProfileFile(property);
let obj = yaml.load(data) as ISeqProfileConfig | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
setPrevData(data);
setCurrData(data);
};
useEffect(() => {
if (currData === "") return;
if (visualization !== true) return;
if (currData === "" || visualization !== true) return;
try {
let obj = yaml.load(currData) as {
prepend: [];
append: [];
@@ -199,6 +256,9 @@ export const ProxiesEditorViewer = (props: Props) => {
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
} catch (e) {
console.error("Error parsing YAML in visualization mode:", e);
}
}, [visualization]);
useEffect(() => {
@@ -212,7 +272,7 @@ export const ProxiesEditorViewer = (props: Props) => {
),
);
} catch (e) {
// 防止异常导致UI卡死
console.error("Error dumping YAML:", e);
}
};
if (window.requestIdleCallback) {
@@ -241,100 +301,69 @@ export const ProxiesEditorViewer = (props: Props) => {
});
return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>
{
<Box display="flex" justifyContent="space-between">
{t("Edit Proxies")}
<Box>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex justify-between items-center">
<DialogTitle>{t("Edit Proxies")}</DialogTitle>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
}}
variant="outline"
size="sm"
onClick={() => setVisualization((prev) => !prev)}
>
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</Box>
</Box>
}
</DialogTitle>
</div>
</DialogHeader>
<DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
>
<div className="flex-1 min-h-0">
{visualization ? (
<>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<Box
sx={{
height: "calc(100% - 80px)",
overflowY: "auto",
}}
>
<Item>
<TextField
autoComplete="new-password"
<div className="h-full flex gap-4">
<div className="w-1/3 flex flex-col gap-4">
<Textarea
placeholder={t("Use newlines for multiple uri")}
fullWidth
rows={9}
multiline
size="small"
className="flex-1"
value={proxyUri}
onChange={(e) => setProxyUri(e.target.value)}
/>
</Item>
</Box>
<Item>
<div className="flex flex-col gap-2">
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setPrependSeq((prev) => [...proxies, ...prev]);
});
}}
onClick={() =>
handleParseAsync((proxies) =>
setPrependSeq((prev) => [...proxies, ...prev]),
)
}
>
<ArrowUpToLine className="mr-2 h-4 w-4" />
{t("Prepend Proxy")}
</Button>
</Item>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setAppendSeq((prev) => [...prev, ...proxies]);
});
}}
onClick={() =>
handleParseAsync((proxies) =>
setAppendSeq((prev) => [...prev, ...proxies]),
)
}
>
<ArrowDownToLine className="mr-2 h-4 w-4" />
{t("Append Proxy")}
</Button>
</Item>
</List>
</div>
</div>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<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
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
className="h-full"
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) {
@@ -345,53 +374,50 @@ export const ProxiesEditorViewer = (props: Props) => {
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name;
})}
items={filteredPrependSeq.map((x) => x.name)}
>
{filteredPrependSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="prepend"
{filteredPrependSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="prepend"
proxy={item}
onDelete={() => {
onDelete={() =>
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
);
}}
)
}
/>
);
})}
))}
</SortableContext>
</DndContext>
);
} else if (index < filteredProxyList.length + shift) {
let newIndex = index - shift;
const newIndex = index - shift;
const currentProxy = filteredProxyList[newIndex];
return (
<ProxyItem
key={`${filteredProxyList[newIndex].name}-${index}`}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
<EditorProxyItem
key={currentProxy.name}
id={currentProxy.name}
p_type={
deleteSeq.includes(currentProxy.name)
? "delete"
: "original"
}
proxy={filteredProxyList[newIndex]}
proxy={currentProxy}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
if (deleteSeq.includes(currentProxy.name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
(v) => v !== currentProxy.name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
currentProxy.name,
]);
}
}}
@@ -405,78 +431,72 @@ export const ProxiesEditorViewer = (props: Props) => {
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name;
})}
items={filteredAppendSeq.map((x) => x.name)}
>
{filteredAppendSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="append"
{filteredAppendSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="append"
proxy={item}
onDelete={() => {
onDelete={() =>
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
);
}}
)
}
/>
);
})}
))}
</SortableContext>
</DndContext>
);
}
}}
/>
</List>
</>
</div>
</div>
</div>
) : (
<div className="h-full rounded-md border">
<MonacoEditor
height="100%"
language="yaml"
value={currData}
theme={themeMode === "light" ? "vs" : "vs-dark"}
options={{
tabSize: 2, // 根据语言类型设置缩进大小
tabSize: 2,
minimap: {
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
mouseWheelZoom: true,
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
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, // 平滑滚动
padding: { top: 16 },
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)}
/>
</div>
)}
</DialogContent>
</div>
<DialogActions>
<Button onClick={onClose} variant="outlined">
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
<Button onClick={handleSave} variant="contained">
</DialogClose>
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
</DialogActions>
</DialogFooter>
</DialogContent>
</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 { 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 {
type: "prepend" | "original" | "delete" | "append";
@@ -16,9 +13,19 @@ interface Props {
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) => {
let { type, proxy, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const { type, proxy, onDelete } = props;
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
const {
attributes,
@@ -27,101 +34,50 @@ export const ProxyItem = (props: Props) => {
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: proxy.name })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
} = useSortable({ id: proxy.name, disabled: !isSortable });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 100 : undefined,
};
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: 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,
})}
<div
ref={setNodeRef}
style={style}
// Применяем условные стили
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
)}
>
<ListItemText
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
ref={setNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
title={proxy.name}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
>
{proxy.name}
</StyledPrimary>
}
secondary={
<ListItemTextChild
sx={{
overflow: "hidden",
display: "flex",
alignItems: "center",
pt: "2px",
}}
>
<Box sx={{ marginTop: "2px" }}>
<StyledTypeBox>{proxy.type}</StyledTypeBox>
</Box>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
<GripVertical className="h-5 w-5" />
</div>
{/* Название и тип прокси */}
<div className="flex-1 min-w-0 ml-2">
<p className="text-sm font-semibold truncate" title={proxy.name}>{proxy.name}</p>
<div className="flex items-center text-xs text-muted-foreground mt-1">
<Badge variant="outline">{proxy.type}</Badge>
</div>
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onDelete}>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
);
};
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 { 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 {
type: "prepend" | "original" | "delete" | "append";
ruleRaw: string;
onDelete: () => void;
}
export const RuleItem = (props: Props) => {
let { type, ruleRaw, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const rule = ruleRaw.replace(",no-resolve", "");
// Определяем стили для каждого типа элемента
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",
};
// Вспомогательная функция для цвета политики прокси
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 proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
@@ -31,112 +50,55 @@ export const RuleItem = (props: Props) => {
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: ruleRaw })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
return (
<ListItem
dense
sx={({ palette }) => ({
position: "relative",
background:
type === "original"
? palette.mode === "dark"
? alpha(palette.background.paper, 0.3)
: 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",
} = useSortable({ id: ruleRaw, disabled: !isSortable });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
})}
zIndex: isDragging ? 100 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
// Применяем условные стили
className={cn(
"flex items-center p-2 mb-1 rounded-lg transition-shadow",
typeStyles[type],
isDragging && "shadow-lg"
)}
>
<ListItemText
{/* Ручка для перетаскивания */}
<div
{...attributes}
{...listeners}
ref={setNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
title={ruleContent || "-"}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
className={cn("p-1 text-muted-foreground rounded-sm", isSortable ? "cursor-move hover:bg-accent" : "cursor-default")}
>
<GripVertical className="h-5 w-5" />
</div>
{/* Основной контент */}
<div className="flex-1 min-w-0 ml-2">
<p className="text-sm font-semibold truncate" title={ruleContent || "-"}>
{ruleContent || "-"}
</StyledPrimary>
}
secondary={
<ListItemTextChild
sx={{
width: "62%",
overflow: "hidden",
display: "flex",
justifyContent: "space-between",
pt: "2px",
}}
>
<Box sx={{ marginTop: "2px" }}>
<StyledTypeBox>{ruleType}</StyledTypeBox>
</Box>
<StyledSubtitle sx={{ color: "text.secondary" }}>
</p>
<div className="flex items-center justify-between text-xs mt-1">
<Badge variant="outline">{ruleType}</Badge>
<p className={cn("font-medium", getProxyColorClass(proxyPolicy))}>
{proxyPolicy}
</StyledSubtitle>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
</p>
</div>
</div>
{/* Кнопка действия */}
<Button variant="ghost" size="icon" className="h-8 w-8 ml-2" onClick={onDelete}>
{type === "delete" ? (
<Undo2 className="h-4 w-4" />
) : (
<Trash2 className="h-4 w-4 text-destructive" />
)}
</Button>
</div>
);
};
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",
}));

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
import { useTranslation } from "react-i18next";
@@ -14,60 +14,71 @@ import {
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
} from "@dnd-kit/sortable";
import {
Autocomplete,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemText,
TextField,
styled,
} from "@mui/material";
import {
VerticalAlignTopRounded,
VerticalAlignBottomRounded,
} from "@mui/icons-material";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import { Switch } from "@/components/base";
import getSystem from "@/utils/get-system";
import { RuleItem } from "@/components/profile/rule-item";
import { BaseSearchBox } from "../base/base-search-box";
import { CSS } from "@dnd-kit/utilities";
import { Virtuoso } from "react-virtuoso";
import MonacoEditor from "react-monaco-editor";
import { readProfileFile, saveProfileFile } from "@/services/cmds";
import getSystem from "@/utils/get-system";
import { useThemeMode } from "@/services/states";
import { showNotice } from "@/services/noticeService";
import { BaseSearchBox } from "../base/base-search-box";
interface Props {
groupsUid: string;
mergeUid: string;
profileUid: string;
property: string;
open: boolean;
onClose: () => void;
onSave?: (prev?: string, curr?: string) => void;
}
// Компоненты shadcn/ui
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
const portValidator = (value: string): boolean => {
return new RegExp(
"^(?:[1-9]\\d{0,3}|[1-5]\\d{4}|6[0-4]\\d{3}|65[0-4]\\d{2}|655[0-2]\\d|6553[0-5])$",
).test(value);
};
const ipv4CIDRValidator = (value: string): boolean => {
return new RegExp(
"^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\\/(?:[12]?[0-9]|3[0-2]))$",
).test(value);
};
const ipv6CIDRValidator = (value: string): boolean => {
return new RegExp(
"^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){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}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$",
).test(value);
};
// Иконки
import {
Check,
ChevronsUpDown,
GripVertical,
Trash2,
Undo2,
ArrowDownToLine,
ArrowUpToLine,
} from "lucide-react";
import { cn } from "@root/lib/utils";
// --- Вспомогательные функции, константы и валидаторы ---
const portValidator = (value: string): boolean =>
/^(?:[1-9]\d{0,3}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5])$/.test(
value,
);
const ipv4CIDRValidator = (value: string): boolean =>
/^(?:(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))\.){3}(?:[1-9]?[0-9]|1[0-9][0-9]|2(?:[0-4][0-9]|5[0-5]))(?:\/(?:[12]?[0-9]|3[0-2]))?$/.test(
value,
);
const ipv6CIDRValidator = (value: string): boolean =>
/^([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){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}:){2}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){3}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){4}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){5}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,6}:)\/(?:12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(
value,
);
const rules: {
name: string;
@@ -76,35 +87,13 @@ const rules: {
noResolve?: boolean;
validator?: (value: string) => boolean;
}[] = [
{
name: "DOMAIN",
example: "example.com",
},
{
name: "DOMAIN-SUFFIX",
example: "example.com",
},
{
name: "DOMAIN-KEYWORD",
example: "example",
},
{
name: "DOMAIN-REGEX",
example: "example.*",
},
{
name: "GEOSITE",
example: "youtube",
},
{
name: "GEOIP",
example: "CN",
noResolve: true,
},
{
name: "SRC-GEOIP",
example: "CN",
},
{ name: "DOMAIN", example: "example.com" },
{ name: "DOMAIN-SUFFIX", example: "example.com" },
{ name: "DOMAIN-KEYWORD", example: "example" },
{ name: "DOMAIN-REGEX", example: "example.*" },
{ name: "GEOSITE", example: "youtube" },
{ name: "GEOIP", example: "CN", noResolve: true },
{ name: "SRC-GEOIP", example: "CN" },
{
name: "IP-ASN",
example: "13335",
@@ -159,10 +148,7 @@ const rules: {
example: "7890",
validator: (value) => portValidator(value),
},
{
name: "DSCP",
example: "4",
},
{ name: "DSCP", example: "4" },
{
name: "PROCESS-NAME",
example: getSystem() === "windows" ? "chrome.exe" : "curl",
@@ -174,10 +160,7 @@ const rules: {
? "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"
: "/usr/bin/wget",
},
{
name: "PROCESS-NAME-REGEX",
example: ".*telegram.*",
},
{ name: "PROCESS-NAME-REGEX", example: ".*telegram.*" },
{
name: "PROCESS-PATH-REGEX",
example:
@@ -193,47 +176,147 @@ const rules: {
example: "1001",
validator: (value) => (+value ? true : false),
},
{
name: "IN-TYPE",
example: "SOCKS/HTTP",
},
{
name: "IN-USER",
example: "mihomo",
},
{
name: "IN-NAME",
example: "ss",
},
{
name: "SUB-RULE",
example: "(NETWORK,tcp)",
},
{
name: "RULE-SET",
example: "providername",
noResolve: true,
},
{
name: "AND",
example: "((DOMAIN,baidu.com),(NETWORK,UDP))",
},
{
name: "OR",
example: "((NETWORK,UDP),(DOMAIN,baidu.com))",
},
{
name: "NOT",
example: "((DOMAIN,baidu.com))",
},
{
name: "MATCH",
required: false,
},
{ name: "IN-TYPE", example: "SOCKS/HTTP" },
{ name: "IN-USER", example: "mihomo" },
{ name: "IN-NAME", example: "ss" },
{ name: "SUB-RULE", example: "(NETWORK,tcp)" },
{ name: "RULE-SET", example: "providername", noResolve: true },
{ name: "AND", example: "((DOMAIN,baidu.com),(NETWORK,UDP))" },
{ name: "OR", example: "((NETWORK,UDP),(DOMAIN,baidu.com))" },
{ name: "NOT", example: "((DOMAIN,baidu.com))" },
{ name: "MATCH", required: false },
];
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
// --- Компонент Combobox для замены Autocomplete ---
const Combobox = ({
options,
value,
onSelect,
placeholder,
}: {
options: string[];
value: string;
onSelect: (value: string) => void;
placeholder?: string;
}) => {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full 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>
<CommandInput placeholder="Search..." />
<CommandEmpty>No results found.</CommandEmpty>
<CommandList>
{options.map((option) => (
<CommandItem
key={option}
value={option}
onSelect={(currentValue) => {
onSelect(
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 EditorRuleItem = ({
type,
ruleRaw,
onDelete,
id,
}: {
type: string;
ruleRaw: string;
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 = 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" : ""}`}
>
{ruleRaw}
</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>
);
};
interface Props {
groupsUid: string;
mergeUid: string;
profileUid: string;
property: string;
open: boolean;
onClose: () => void;
onSave?: (prev?: string, curr?: string) => void;
}
export const RulesEditorViewer = (props: Props) => {
const { groupsUid, mergeUid, profileUid, property, open, onClose, onSave } =
props;
@@ -244,7 +327,6 @@ export const RulesEditorViewer = (props: Props) => {
const [currData, setCurrData] = useState("");
const [visualization, setVisualization] = useState(true);
const [match, setMatch] = useState(() => (_: string) => true);
const [ruleType, setRuleType] = useState<(typeof rules)[number]>(rules[0]);
const [ruleContent, setRuleContent] = useState("");
const [noResolve, setNoResolve] = useState(false);
@@ -253,7 +335,6 @@ export const RulesEditorViewer = (props: Props) => {
const [ruleList, setRuleList] = useState<string[]>([]);
const [ruleSetList, setRuleSetList] = useState<string[]>([]);
const [subRuleList, setSubRuleList] = useState<string[]>([]);
const [prependSeq, setPrependSeq] = useState<string[]>([]);
const [appendSeq, setAppendSeq] = useState<string[]>([]);
const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
@@ -285,49 +366,48 @@ export const RulesEditorViewer = (props: Props) => {
};
const onPrependDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
if (over && active.id !== over.id) {
let activeIndex = prependSeq.indexOf(active.id.toString());
let overIndex = prependSeq.indexOf(over.id.toString());
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
}
};
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (active.id !== over.id) {
if (over && active.id !== over.id) {
let activeIndex = appendSeq.indexOf(active.id.toString());
let overIndex = appendSeq.indexOf(over.id.toString());
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
}
};
const fetchContent = async () => {
try {
let data = await readProfileFile(property);
let obj = yaml.load(data) as ISeqProfileConfig | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
setPrevData(data);
setCurrData(data);
} catch (error) {
console.error("Failed to fetch or parse content:", error);
}
};
useEffect(() => {
if (currData === "") return;
if (visualization !== true) return;
if (currData === "" || !visualization) return;
try {
let obj = yaml.load(currData) as ISeqProfileConfig | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
} catch (e) {
// Ignore parsing errors while typing
}
}, [visualization]);
// 优化异步处理大数据yaml.dump避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq) {
if (prependSeq && appendSeq && deleteSeq && visualization) {
const serialize = () => {
try {
setCurrData(
@@ -346,16 +426,16 @@ export const RulesEditorViewer = (props: Props) => {
setTimeout(serialize, 0);
}
}
}, [prependSeq, appendSeq, deleteSeq]);
}, [prependSeq, appendSeq, deleteSeq, visualization]);
const fetchProfile = async () => {
let data = await readProfileFile(profileUid); // 原配置文件
let groupsData = await readProfileFile(groupsUid); // groups配置文件
let mergeData = await readProfileFile(mergeUid); // merge配置文件
let globalMergeData = await readProfileFile("Merge"); // global merge配置文件
try {
let data = await readProfileFile(profileUid);
let groupsData = await readProfileFile(groupsUid);
let mergeData = await readProfileFile(mergeUid);
let globalMergeData = await readProfileFile("Merge");
let rulesObj = yaml.load(data) as { rules: [] } | null;
let originGroupsObj = yaml.load(data) as { "proxy-groups": [] } | null;
let originGroups = originGroupsObj?.["proxy-groups"] || [];
let moreGroupsObj = yaml.load(groupsData) as ISeqProfileConfig | null;
@@ -364,13 +444,12 @@ export const RulesEditorViewer = (props: Props) => {
let moreDeleteGroups =
moreGroupsObj?.["delete"] || ([] as string[] | { name: string }[]);
let groups = morePrependGroups.concat(
originGroups.filter((group: any) => {
if (group.name) {
return !moreDeleteGroups.includes(group.name);
} else {
return !moreDeleteGroups.includes(group);
}
}),
originGroups.filter(
(group: any) =>
!moreDeleteGroups.some(
(del: any) => (del.name || del) === group.name,
),
),
moreAppendGroups,
);
@@ -384,7 +463,7 @@ export const RulesEditorViewer = (props: Props) => {
"rule-providers": {};
} | null;
let globalRuleSet = globalRuleSetObj?.["rule-providers"] || {};
let ruleSet = Object.assign({}, originRuleSet, moreRuleSet, globalRuleSet);
let ruleSet = { ...originRuleSet, ...moreRuleSet, ...globalRuleSet };
let originSubRuleObj = yaml.load(data) as { "sub-rules": {} } | null;
let originSubRule = originSubRuleObj?.["sub-rules"] || {};
@@ -394,19 +473,24 @@ export const RulesEditorViewer = (props: Props) => {
"sub-rules": {};
} | null;
let globalSubRule = globalSubRuleObj?.["sub-rules"] || {};
let subRule = Object.assign({}, originSubRule, moreSubRule, globalSubRule);
let subRule = { ...originSubRule, ...moreSubRule, ...globalSubRule };
setProxyPolicyList(
builtinProxyPolicies.concat(groups.map((group: any) => group.name)),
);
setRuleSetList(Object.keys(ruleSet));
setSubRuleList(Object.keys(subRule));
setRuleList(rulesObj?.rules || []);
} catch (error) {
console.error("Failed to fetch profile data for editor:", error);
}
};
useEffect(() => {
if (!open) return;
if (open) {
fetchContent();
fetchProfile();
}
}, [open]);
const validateRule = () => {
@@ -416,11 +500,8 @@ export const RulesEditorViewer = (props: Props) => {
if (ruleType.validator && !ruleType.validator(ruleContent)) {
throw new Error(t("Invalid Rule"));
}
const condition = (ruleType.required ?? true) ? ruleContent : "";
return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${
ruleType.noResolve && noResolve ? ",no-resolve" : ""
}`;
return `${ruleType.name}${condition ? "," + condition : ""},${proxyPolicy}${noResolve && ruleType.noResolve ? ",no-resolve" : ""}`;
};
const handleSave = useLockFn(async () => {
@@ -435,171 +516,124 @@ export const RulesEditorViewer = (props: Props) => {
});
return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>
{
<Box display="flex" justifyContent="space-between">
{t("Edit Rules")}
<Box>
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl h-[80vh] flex flex-col">
<DialogHeader>
<div className="flex justify-between items-center">
<DialogTitle>{t("Edit Rules")}</DialogTitle>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
}}
variant="outline"
size="sm"
onClick={() => setVisualization((prev) => !prev)}
>
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</Box>
</Box>
}
</DialogTitle>
</div>
</DialogHeader>
<DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
>
<div className="flex-1 min-h-0 mt-4">
{visualization ? (
<>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<Item>
<ListItemText primary={t("Rule Type")} />
<Autocomplete
size="small"
sx={{ minWidth: "240px" }}
renderInput={(params) => <TextField {...params} />}
options={rules}
value={ruleType}
getOptionLabel={(option) => option.name}
renderOption={(props, option) => (
<li {...props} title={t(option.name)}>
{option.name}
</li>
)}
onChange={(_, value) => value && setRuleType(value)}
<div className="h-full flex gap-4">
<div className="w-1/2 flex flex-col gap-4 p-1">
<div className="space-y-2">
<Label>{t("Rule Type")}</Label>
<Combobox
options={rules.map((r) => r.name)}
value={ruleType.name}
onSelect={(val) =>
setRuleType(
rules.find(
(r) => r.name.toLowerCase() === val.toLowerCase(),
) || rules[0],
)
}
/>
</Item>
<Item
sx={{ display: !(ruleType.required ?? true) ? "none" : "" }}
>
<ListItemText primary={t("Rule Content")} />
{ruleType.name === "RULE-SET" && (
<Autocomplete
size="small"
sx={{ minWidth: "240px" }}
renderInput={(params) => <TextField {...params} />}
options={ruleSetList}
</div>
{(ruleType.required ?? true) && (
<div className="space-y-2">
<Label>{t("Rule Content")}</Label>
{ruleType.name === "RULE-SET" ||
ruleType.name === "SUB-RULE" ? (
<Combobox
options={
ruleType.name === "RULE-SET"
? ruleSetList
: subRuleList
}
value={ruleContent}
onChange={(_, value) => value && setRuleContent(value)}
onSelect={setRuleContent}
/>
)}
{ruleType.name === "SUB-RULE" && (
<Autocomplete
size="small"
sx={{ minWidth: "240px" }}
renderInput={(params) => <TextField {...params} />}
options={subRuleList}
) : (
<Input
value={ruleContent}
onChange={(_, value) => value && setRuleContent(value)}
/>
)}
{ruleType.name !== "RULE-SET" &&
ruleType.name !== "SUB-RULE" && (
<TextField
autoComplete="new-password"
size="small"
sx={{ minWidth: "240px" }}
value={ruleContent}
required={ruleType.required ?? true}
error={(ruleType.required ?? true) && !ruleContent}
placeholder={ruleType.example}
onChange={(e) => setRuleContent(e.target.value)}
/>
)}
</Item>
<Item>
<ListItemText primary={t("Proxy Policy")} />
<Autocomplete
size="small"
sx={{ minWidth: "240px" }}
renderInput={(params) => <TextField {...params} />}
</div>
)}
<div className="space-y-2">
<Label>{t("Proxy Policy")}</Label>
<Combobox
options={proxyPolicyList}
value={proxyPolicy}
renderOption={(props, option) => (
<li {...props} title={t(option)}>
{option}
</li>
)}
onChange={(_, value) => value && setProxyPolicy(value)}
onSelect={setProxyPolicy}
/>
</Item>
</div>
{ruleType.noResolve && (
<Item>
<ListItemText primary={t("No Resolve")} />
<div className="flex items-center space-x-2 pt-2">
<Switch
id="no-resolve-switch"
checked={noResolve}
onChange={() => setNoResolve(!noResolve)}
onCheckedChange={setNoResolve}
/>
</Item>
<Label htmlFor="no-resolve-switch">{t("No Resolve")}</Label>
</div>
)}
<Item>
<div className="flex flex-col gap-2 mt-auto">
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
try {
let raw = validateRule();
if (prependSeq.includes(raw)) return;
const raw = validateRule();
if (!prependSeq.includes(raw))
setPrependSeq([raw, ...prependSeq]);
} catch (err: any) {
showNotice("error", err.message || err.toString());
showNotice("error", err.message);
}
}}
>
<ArrowUpToLine className="mr-2 h-4 w-4" />
{t("Prepend Rule")}
</Button>
</Item>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
try {
let raw = validateRule();
if (appendSeq.includes(raw)) return;
const raw = validateRule();
if (!appendSeq.includes(raw))
setAppendSeq([...appendSeq, raw]);
} catch (err: any) {
showNotice("error", err.message || err.toString());
showNotice("error", err.message);
}
}}
>
<ArrowDownToLine className="mr-2 h-4 w-4" />
{t("Append Rule")}
</Button>
</Item>
</List>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
</div>
</div>
<Separator orientation="vertical" />
<div className="w-1/2 flex flex-col">
<BaseSearchBox
onSearch={(matcher) => setMatch(() => matcher)}
/>
<div className="flex-1 min-h-0 mt-2 rounded-md border">
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
className="h-full"
totalCount={
filteredRuleList.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) {
@@ -609,51 +643,43 @@ export const RulesEditorViewer = (props: Props) => {
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x;
})}
>
{filteredPrependSeq.map((item, index) => {
return (
<RuleItem
key={`${item}-${index}`}
<SortableContext items={filteredPrependSeq}>
{filteredPrependSeq.map((item) => (
<EditorRuleItem
key={item}
id={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
onDelete={() =>
setPrependSeq(
prependSeq.filter((v) => v !== item),
);
}}
)
}
/>
);
})}
))}
</SortableContext>
</DndContext>
);
} else if (index < filteredRuleList.length + shift) {
let newIndex = index - shift;
const newIndex = index - shift;
const currentRule = filteredRuleList[newIndex];
return (
<RuleItem
key={`${filteredRuleList[newIndex]}-${index}`}
<EditorRuleItem
key={currentRule}
id={currentRule}
type={
deleteSeq.includes(filteredRuleList[newIndex])
deleteSeq.includes(currentRule)
? "delete"
: "original"
}
ruleRaw={filteredRuleList[newIndex]}
ruleRaw={currentRule}
onDelete={() => {
if (deleteSeq.includes(filteredRuleList[newIndex])) {
if (deleteSeq.includes(currentRule)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredRuleList[newIndex],
),
deleteSeq.filter((v) => v !== currentRule),
);
} else {
setDeleteSeq((prev) => [
...prev,
filteredRuleList[newIndex],
]);
setDeleteSeq((prev) => [...prev, currentRule]);
}
}}
/>
@@ -665,77 +691,69 @@ export const RulesEditorViewer = (props: Props) => {
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x;
})}
>
{filteredAppendSeq.map((item, index) => {
return (
<RuleItem
key={`${item}-${index}`}
<SortableContext items={filteredAppendSeq}>
{filteredAppendSeq.map((item) => (
<EditorRuleItem
key={item}
id={item}
type="append"
ruleRaw={item}
onDelete={() => {
onDelete={() =>
setAppendSeq(
appendSeq.filter((v) => v !== item),
);
}}
)
}
/>
);
})}
))}
</SortableContext>
</DndContext>
);
}
}}
/>
</List>
</>
</div>
</div>
</div>
) : (
<div className="h-full rounded-md border">
<MonacoEditor
height="100%"
language="yaml"
value={currData}
theme={themeMode === "light" ? "vs" : "vs-dark"}
options={{
tabSize: 2, // 根据语言类型设置缩进大小
tabSize: 2,
minimap: {
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
mouseWheelZoom: true,
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
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, // 平滑滚动
padding: { top: 16 },
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)}
/>
</div>
)}
</DialogContent>
</div>
<DialogActions>
<Button onClick={onClose} variant="outlined">
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
<Button onClick={handleSave} variant="contained">
</DialogClose>
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
</DialogActions>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

@@ -1,31 +1,24 @@
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 { useLockFn } from "ahooks";
import { proxyProviderUpdate } from "@/services/api";
import { useAppData } from "@/providers/app-data-provider";
import { showNotice } from "@/services/noticeService";
import { StorageOutlined, RefreshRounded } from "@mui/icons-material";
import { Database, RefreshCw } from "lucide-react";
import dayjs from "dayjs";
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 {
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) => {
if (!expire) return "-";
@@ -61,7 +41,6 @@ const parseExpire = (expire?: number) => {
export const ProviderButton = () => {
const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false);
const { proxyProviders, refreshProxy, refreshProxyProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
@@ -138,52 +117,40 @@ export const ProviderButton = () => {
}
});
const handleClose = () => {
setOpen(false);
};
if (!hasProviders) return null;
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<StorageOutlined />}
onClick={() => setOpen(true)}
sx={{ mr: 1 }}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="mr-1">
<Database className="mr-2 h-4 w-4" />
{t("Proxy Provider")}
</Button>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
</DialogTrigger>
<DialogContent className="max-w-md sm:max-w-lg md:max-w-xl lg:max-w-2xl max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Typography variant="h6">{t("Proxy Provider")}</Typography>
<Box>
<div className="flex justify-between items-center">
<span>{t("Proxy Provider")}</span>
<Button
variant="contained"
size="small"
variant="default"
size="sm"
onClick={updateAllProviders}
disabled={Object.values(updating).some(Boolean)}
>
{t("Update All")}
</Button>
</Box>
</Box>
</div>
</DialogTitle>
</DialogHeader>
<DialogContent>
<List sx={{ py: 0, minHeight: 250 }}>
<div className="flex-grow overflow-y-auto py-0 px-1 my-2">
<div className="space-y-2">
{Object.entries(proxyProviders || {}).map(([key, item]) => {
const provider = item as ProxyProviderItem;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
// 订阅信息
const sub = provider.subscriptionInfo;
const hasSubInfo = !!sub;
const upload = sub?.Upload || 0;
@@ -191,7 +158,6 @@ export const ProviderButton = () => {
const total = sub?.Total || 0;
const expire = sub?.Expire || 0;
// 流量使用进度
const progress =
total > 0
? Math.min(
@@ -200,82 +166,40 @@ export const ProviderButton = () => {
)
: 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 (
<ListItem
<div
key={key}
sx={[
{
p: 0,
mb: "8px",
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s",
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const hoverColor =
mode === "light"
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
return {
backgroundColor: bgcolor,
"&:hover": {
backgroundColor: hoverColor,
},
};
},
]}
className="p-3 rounded-lg border bg-card text-card-foreground shadow-sm flex items-center"
>
<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">
<div className="flex-grow space-y-1">
<div className="flex justify-between items-center">
<div className="flex items-center font-semibold truncate">
<span className="mr-2 truncate" title={key}>
{key}
</span>
<TypeBoxDisplay>
{provider.proxies.length}
</TypeBox>
<TypeBox component="span">
{provider.vehicleType}
</TypeBox>
</Typography>
<Typography
variant="body2"
color="text.secondary"
noWrap
>
</TypeBoxDisplay>
<TypeBoxDisplay>{provider.vehicleType}</TypeBoxDisplay>
</div>
<div className="text-xs text-muted-foreground whitespace-nowrap">
<small>{t("Update At")}: </small>
{time.fromNow()}
</Typography>
</Box>
}
secondary={
<>
{/* 订阅信息 */}
</div>
</div>
{hasSubInfo && (
<>
<Box
sx={{
mb: 1,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div className="text-xs">
<div className="flex items-center justify-between mb-1">
<span title={t("Used / Total") as string}>
{parseTraffic(upload + download)} /{" "}
{parseTraffic(total)}
@@ -283,65 +207,37 @@ export const ProviderButton = () => {
<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>
{total > 0 && (
<Progress value={progress} className="h-1.5" />
)}
</>
}
/>
<Divider orientation="vertical" flexItem />
<Box
sx={{
width: 40,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<IconButton
size="small"
color="primary"
onClick={(e) => {
updateProvider(key);
}}
</div>
)}
</div>
<div className="pl-3 ml-3 border-l border-border flex-shrink-0">
<Button
variant="ghost"
size="icon"
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}
className={isUpdating ? "animate-spin" : ""}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</List>
</DialogContent>
</div>
</div>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("Close")}
</Button>
</DialogActions>
</DialogFooter>
</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 { Virtuoso, type VirtuosoHandle } from "react-virtuoso";
import {
@@ -16,550 +23,14 @@ import { ProxyRender } from "./proxy-render";
import delayManager from "@/services/delay";
import { useTranslation } from "react-i18next";
import { ScrollTopButton } from "../layout/scroll-top-button";
import { Box, styled } from "@mui/material";
import { memo } from "react";
import { createPortal } from "react-dom";
import {
Tooltip,
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>(
func: T,
wait: number,
@@ -588,14 +59,248 @@ function throttle<T extends (...args: any[]) => any>(
};
}
// 保留防抖函数以兼容其他地方可能的使用
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
// Компонент для одной буквы в навигаторе, переписанный на Tailwind и shadcn/ui
const LetterItem = memo(
({
name,
onClick,
getFirstChar,
}: {
name: string;
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 { 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 type { HeadState } from "./use-head-state";
import type { ProxySortType } from "./use-filter-sort";
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 {
sx?: SxProps;
url?: string;
groupName: string;
headState: HeadState;
@@ -30,140 +41,181 @@ interface 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 { t } = useTranslation();
const [autoFocus, setAutoFocus] = useState(false);
const { verge } = useVerge();
const [autoFocus, setAutoFocus] = useState(false);
useEffect(() => {
// fix the focus conflict
const timer = setTimeout(() => setAutoFocus(true), 100);
return () => clearTimeout(timer);
}, []);
const { verge } = useVerge();
useEffect(() => {
delayManager.setUrl(
groupName,
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 (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5, ...sx }}>
<IconButton
size="small"
color="inherit"
<TooltipProvider delayDuration={100}>
<div className="flex h-10 items-center justify-between px-2">
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t("locate")}
onClick={props.onLocation}
className="h-8 w-8"
>
<MyLocationRounded />
</IconButton>
<LocateFixed className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Locate Current Proxy")}</p>
</TooltipContent>
</Tooltip>
<IconButton
size="small"
color="inherit"
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
title={t("Delay check")}
onClick={() => {
console.log(`[ProxyHead] 点击延迟测试按钮,组: ${groupName}`);
// Remind the user that it is custom test url
if (testUrl?.trim() && textState !== "filter") {
console.log(`[ProxyHead] 使用自定义测试URL: ${testUrl}`);
onHeadState({ textState: "url" });
}
props.onCheckDelay();
}}
onClick={props.onCheckDelay}
className="h-8 w-8"
>
<NetworkCheckRounded />
</IconButton>
<Network className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Check Group Latency")}</p>
</TooltipContent>
</Tooltip>
<IconButton
size="small"
color="inherit"
title={
<Separator orientation="vertical" className="h-6 mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() =>
onHeadState({
sortType: ((sortType + 1) % 3) as ProxySortType,
})
}
className="h-8 w-8"
>
{sortType === 0 && <ArrowUpDown className="h-5 w-5" />}
{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
]
}
onClick={() =>
onHeadState({ sortType: ((sortType + 1) % 3) as ProxySortType })
}
>
{sortType !== 1 && sortType !== 2 && <SortRounded />}
{sortType === 1 && <AccessTimeRounded />}
{sortType === 2 && <SortByAlphaRounded />}
</IconButton>
</p>
</TooltipContent>
</Tooltip>
<IconButton
size="small"
color="inherit"
title={t("Delay check URL")}
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => onHeadState({ showType: !showType })}
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>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={getToggleVariant(textState === "url")}
size="icon"
onClick={() =>
onHeadState({ textState: textState === "url" ? null : "url" })
}
className="h-8 w-8"
>
{textState === "url" ? (
<WifiTetheringRounded />
) : (
<WifiTetheringOffRounded />
)}
</IconButton>
<Wifi className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Set Latency Test URL")}</p>
</TooltipContent>
</Tooltip>
<IconButton
size="small"
color="inherit"
title={showType ? t("Proxy basic") : t("Proxy detail")}
onClick={() => onHeadState({ showType: !showType })}
>
{showType ? <VisibilityRounded /> : <VisibilityOffRounded />}
</IconButton>
<IconButton
size="small"
color="inherit"
title={t("Filter")}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={getToggleVariant(textState === "filter")}
size="icon"
onClick={() =>
onHeadState({ textState: textState === "filter" ? null : "filter" })
onHeadState({
textState: textState === "filter" ? null : "filter",
})
}
className="h-8 w-8"
>
{textState === "filter" ? (
<FilterAltRounded />
) : (
<FilterAltOffRounded />
)}
</IconButton>
<Filter className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Filter by Name")}</p>
</TooltipContent>
</Tooltip>
<div
className={cn(
"transition-all duration-300 ease-in-out",
textState ? "w-48 ml-2" : "w-0",
)}
>
{textState === "filter" && (
<TextField
autoComplete="new-password"
<Input
autoFocus={autoFocus}
hiddenLabel
value={filterText}
size="small"
variant="outlined"
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"
/>
)}
{textState === "url" && (
<TextField
autoComplete="new-password"
<Input
autoFocus={autoFocus}
hiddenLabel
autoSave="off"
value={testUrl}
size="small"
variant="outlined"
placeholder={t("Delay check URL")}
onChange={(e) => onHeadState({ testUrl: e.target.value })}
sx={{ ml: 0.5, flex: "1 1 auto", input: { py: 0.65, px: 1 } }}
className="h-8"
/>
)}
</Box>
</div>
</div>
</div>
</TooltipProvider>
);
};

View File

@@ -1,11 +1,14 @@
// ProxyItemMini.tsx
import { useEffect, useState } from "react";
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 delayManager from "@/services/delay";
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 {
group: IProxyGroupItem;
@@ -15,16 +18,20 @@ interface Props {
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) => {
const { group, proxy, selected, showType = true, onClick } = props;
const { t } = useTranslation();
const presetList = ["DIRECT", "REJECT", "REJECT-DROP", "PASS", "COMPATIBLE"];
const isPreset = presetList.includes(proxy.name);
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1);
const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000;
@@ -32,205 +39,97 @@ export const ProxyItemMini = (props: Props) => {
useEffect(() => {
if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelay);
return () => {
delayManager.removeListener(proxy.name, group.name);
};
}, [proxy.name, group.name]);
return () => delayManager.removeListener(proxy.name, group.name);
}, [proxy.name, group.name, isPreset]);
useEffect(() => {
if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, group.name));
}, [proxy]);
}, [proxy, group.name]);
const onDelay = useLockFn(async () => {
setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout));
});
return (
<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;
const handleItemClick = () => onClick?.(proxy.name);
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"& .the-pin, & .the-unpin": {
position: "absolute",
fontSize: "12px",
top: "-5px",
right: "-5px",
},
"& .the-unpin": { filter: "grayscale(1)" },
"&.Mui-selected": {
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`,
bgcolor:
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35),
},
backgroundColor: bgcolor,
const handleDelayClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!proxy.provider) onDelay();
};
},
]}
>
<Box
return (
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
// Увеличиваем высоту (h-16) и внутренние отступы (p-3)
<div
data-selected={selected}
onClick={handleItemClick}
title={`${proxy.name}\n${proxy.now ?? ""}`}
sx={{ overflow: "hidden" }}
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"
>
<Typography
variant="body2"
component="div"
color="text.primary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
}}
>
{proxy.name}
</Typography>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{proxy.name}</p>
{showType && (
<Box
sx={{
display: "flex",
flexWrap: "nowrap",
flex: "none",
marginTop: "4px",
}}
>
<div className="mt-1.5 flex items-center gap-1.5 overflow-hidden">
{proxy.now && (
<Typography
variant="body2"
component="div"
color="text.secondary"
sx={{
display: "block",
textOverflow: "ellipsis",
wordBreak: "break-all",
overflow: "hidden",
whiteSpace: "nowrap",
marginRight: "8px",
}}
>
<span className="truncate text-xs text-muted-foreground">
{proxy.now}
</Typography>
</span>
)}
{!!proxy.provider && (
<TypeBox color="text.secondary" component="span">
<Badge variant="outline" className="flex-shrink-0">
{proxy.provider}
</TypeBox>
</Badge>
)}
<TypeBox color="text.secondary" component="span">
<Badge variant="outline" className="flex-shrink-0">
{proxy.type}
</TypeBox>
</Badge>
{proxy.udp && (
<TypeBox color="text.secondary" component="span">
<Badge variant="outline" className="flex-shrink-0">
UDP
</TypeBox>
</Badge>
)}
{proxy.xudp && (
<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>
)}
</div>
{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) } }
: {}
}
<div className="ml-2 flex h-6 w-14 items-center justify-end text-sm">
{isPreset ? null : delay === -2 ? (
<div className="flex items-center text-muted-foreground">
<BaseLoading className="h-4 w-4" />
</div>
) : delay > 0 ? (
<div
onClick={handleDelayClick}
className={`font-medium ${getDelayColorClass(delay)} ${!proxy.provider ? "hover:opacity-70" : "cursor-default"}`}
>
{delayManager.formatDelay(delay, timeout)}
</Widget>
</div>
) : (
<>
{selected && (
<CheckCircle2 className="h-5 w-5 text-primary group-hover:hidden" />
)}
{delay !== -2 && delay <= 0 && selected && (
// 展示已选择的icon
<CheckCircleOutlineRounded
className="the-icon"
sx={{ fontSize: 16, mr: 0.5, display: "block" }}
/>
{!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>
)}
</Box>
{group.fixed && group.fixed === proxy.name && (
// 展示fixed状态
</>
)}
</div>
{group.fixed === proxy.name && (
<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={
group.type === "URLTest" ? t("Delay check to cancel fixed") : ""
}
@@ -238,29 +137,6 @@ export const ProxyItemMini = (props: Props) => {
📌
</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 { 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 delayManager from "@/services/delay";
// Новые импорты
import { CheckCircle2, RefreshCw } from "lucide-react";
import { BaseLoading } from "@/components/base";
import { Badge } from "@/components/ui/badge";
interface Props {
group: IProxyGroupItem;
proxy: IProxyItem;
selected: boolean;
showType?: boolean;
sx?: SxProps<Theme>;
onClick?: (name: string) => void;
}
const Widget = styled(Box)(() => ({
padding: "3px 6px",
fontSize: 14,
borderRadius: "4px",
}));
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,
}));
// Вспомогательная функция для определения цвета задержки
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 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 isPreset = presetList.includes(proxy.name);
// -1/<=0 为 不显示
// -2 为 loading
const [delay, setDelay] = useState(-1);
const { verge } = useVerge();
const timeout = verge?.default_latency_timeout || 10000;
// Вся логика хуков остается без изменений
useEffect(() => {
if (isPreset) return;
delayManager.setListener(proxy.name, group.name, setDelay);
return () => {
delayManager.removeListener(proxy.name, group.name);
};
}, [proxy.name, group.name]);
}, [proxy.name, group.name, isPreset]);
useEffect(() => {
if (!proxy) return;
setDelay(delayManager.getDelayFix(proxy, group.name));
}, [proxy]);
}, [proxy, group.name]);
const onDelay = useLockFn(async () => {
setDelay(-2);
setDelay(await delayManager.checkDelay(proxy.name, group.name, timeout));
setDelay(-2); // -2 это состояние загрузки
const newDelay = await delayManager.checkDelay(
proxy.name,
group.name,
timeout,
);
setDelay(newDelay);
});
return (
<ListItem sx={sx}>
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{ borderRadius: 1 },
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const selectColor = mode === "light" ? primary.main : primary.light;
const showDelay = delay > 0;
return {
"&:hover .the-check": { display: !showDelay ? "block" : "none" },
"&:hover .the-delay": { display: showDelay ? "block" : "none" },
"&:hover .the-icon": { display: "none" },
"&.Mui-selected": {
width: `calc(100% + 3px)`,
marginLeft: `-3px`,
borderLeft: `3px solid ${selectColor}`,
bgcolor:
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35),
},
backgroundColor: bgcolor,
marginBottom: "8px",
height: "40px",
const handleItemClick = () => {
if (onClick) {
onClick(proxy.name);
}
};
},
]}
const handleDelayClick = (e: React.MouseEvent) => {
e.stopPropagation(); // Останавливаем всплытие, чтобы не сработал клик по всей строке
if (!proxy.provider) {
onDelay();
}
};
return (
// 1. Основной контейнер. Добавляем `group` для hover-эффектов на дочерних элементах.
// Атрибут data-selected используется для стилизации выделенного элемента.
<div
data-selected={selected}
onClick={handleItemClick}
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"
>
<ListItemText
title={proxy.name}
secondary={
{/* Левая часть с названием и тегами */}
<div className="flex-1 min-w-0">
<p className="truncate font-medium text-sm">{proxy.name}</p>
{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>
{/* Правая часть с индикатором задержки */}
<div className="ml-4 flex h-6 w-20 items-center justify-end text-sm">
{isPreset ? null : delay === -2 ? ( // Состояние загрузки
<div className="flex items-center text-muted-foreground">
<BaseLoading className="w-4 h-4" />
</div>
) : delay > 0 ? ( // Состояние с задержкой
<div
onClick={handleDelayClick}
className={`font-medium ${getDelayColorClass(delay)} ${!proxy.provider ? "hover:opacity-70" : "cursor-default"}`}
>
{delayManager.formatDelay(delay, timeout)} ms
</div>
) : (
// Состояние по умолчанию (до проверки)
<>
<Box
sx={{
display: "inline-block",
marginRight: "8px",
fontSize: "14px",
color: "text.primary",
}}
>
{proxy.name}
{showType && proxy.now && ` - ${proxy.now}`}
</Box>
{showType && !!proxy.provider && (
<TypeBox>{proxy.provider}</TypeBox>
{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>
)}
{showType && <TypeBox>{proxy.type}</TypeBox>}
{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>
</div>
</div>
);
};

View File

@@ -1,29 +1,23 @@
import {
alpha,
Box,
ListItemText,
ListItemButton,
Typography,
styled,
Chip,
Tooltip,
} from "@mui/material";
import {
ExpandLessRounded,
ExpandMoreRounded,
InboxRounded,
} from "@mui/icons-material";
// ProxyRender.tsx
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { HeadState } from "./use-head-state";
import { ProxyHead } from "./proxy-head";
import { ProxyItem } from "./proxy-item";
import { ProxyItemMini } from "./proxy-item-mini";
import type { IRenderItem } from "./use-render-list";
import { useVerge } from "@/hooks/use-verge";
import { useThemeMode } from "@/services/states";
import { useEffect, useMemo, useState } from "react";
import { convertFileSrc } from "@tauri-apps/api/core";
import { downloadIconCache } from "@/services/cmds";
import { useTranslation } from "react-i18next";
// Новые импорты из lucide-react и shadcn/ui
import { ChevronDown, ChevronUp, Inbox } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface RenderProps {
item: IRenderItem;
@@ -44,115 +38,72 @@ export const ProxyRender = (props: RenderProps) => {
const { type, group, headState, proxy, proxyCol } = item;
const { verge } = useVerge();
const enable_group_icon = verge?.enable_group_icon ?? true;
const mode = useThemeMode();
const isDark = mode === "light" ? false : true;
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);
}
// Логика с иконками остается, но ее нужно будет адаптировать, если она тоже использует MUI
// В данном рефакторинге мы предполагаем, что иконки - это просто URL или SVG-строки
// Рендер заголовка группы (type 0)
if (type === 0) {
return (
<ListItemButton
dense
style={{
background: itembackgroundcolor,
height: "100%",
margin: "8px 8px",
borderRadius: "8px",
}}
<div
className="flex items-center mx-2 my-1 p-3 rounded-lg bg-card hover:bg-accent cursor-pointer transition-colors"
onClick={() => onHeadState(group.name, { open: !headState?.open })}
>
{enable_group_icon &&
group.icon &&
group.icon.trim().startsWith("http") && (
{/* Логика иконок групп (сохранена) */}
{enable_group_icon && group.icon && (
<img
src={iconCachePath === "" ? group.icon : iconCachePath}
width="32px"
style={{ marginRight: "12px", borderRadius: "6px" }}
/>
)}
{enable_group_icon &&
group.icon &&
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>
src={
group.icon.startsWith("data")
? group.icon
: group.icon.startsWith("<svg")
? `data:image/svg+xml;base64,${btoa(group.icon)}`
: group.icon
}
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,
}}
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>
{headState?.open ? <ExpandLessRounded /> : <ExpandMoreRounded />}
</Box>
</ListItemButton>
</TooltipProvider>
{headState?.open ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</div>
</div>
);
}
// Рендер шапки с кнопками управления группой (type 1)
// Компонент ProxyHead не меняем, только его контейнер
if (type === 1) {
return (
<div className={indent ? "mt-1" : "mt-0.5"}>
<ProxyHead
sx={{ pl: 2, pr: 3, mt: indent ? 1 : 0.5, mb: 1 }}
url={group.testUrl}
groupName={group.name}
headState={headState!}
@@ -160,9 +111,12 @@ export const ProxyRender = (props: RenderProps) => {
onCheckDelay={() => onCheckAll(group.name)}
onHeadState={(p) => onHeadState(group.name, p)}
/>
</div>
);
}
// Рендер полного элемента прокси (type 2)
// Компонент ProxyItem не меняем
if (type === 2) {
return (
<ProxyItem
@@ -170,87 +124,45 @@ export const ProxyRender = (props: RenderProps) => {
proxy={proxy!}
selected={group.now === proxy?.name}
showType={headState?.showType}
sx={{ py: 0, pl: 2 }}
onClick={() => onChangeProxy(group, proxy!)}
/>
);
}
// Рендер заглушки "No Proxies" (type 3)
if (type === 3) {
return (
<Box
sx={{
py: 2,
pl: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
<InboxRounded sx={{ fontSize: "2.5em", color: "inherit" }} />
<Typography sx={{ color: "inherit" }}>No Proxies</Typography>
</Box>
<div className="flex flex-col items-center justify-center p-4 text-muted-foreground">
<Inbox className="w-12 h-12" />
<p>No Proxies</p>
</div>
);
}
// Рендер сетки мини-прокси (type 4)
if (type === 4) {
const proxyColItemsMemo = useMemo(() => {
return proxyCol?.map((proxy) => (
return proxyCol?.map((p) => (
<ProxyItemMini
key={item.key + proxy.name}
key={item.key + p.name}
group={group}
proxy={proxy!}
selected={group.now === proxy.name}
proxy={p}
selected={group.now === p.name}
showType={headState?.showType}
onClick={() => onChangeProxy(group, proxy!)}
onClick={() => onChangeProxy(group, p)}
/>
));
}, [proxyCol, group, headState]);
}, [proxyCol, group, headState, item.key, onChangeProxy]);
return (
<Box
sx={{
height: 56,
display: "grid",
gap: 1,
pl: 2,
pr: 2,
pb: 1,
gridTemplateColumns: `repeat(${item.col! || 2}, 1fr)`,
}}
<div
className="grid gap-2 p-2"
style={{ gridTemplateColumns: `repeat(${item.col || 2}, 1fr)` }}
>
{proxyColItemsMemo}
</Box>
</div>
);
}
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 {
Button,
Box,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
List,
ListItem,
ListItemText,
Typography,
Divider,
alpha,
styled,
useTheme,
} from "@mui/material";
import { useTranslation } from "react-i18next";
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 { useAppData } from "@/providers/app-data-provider";
import { ruleProviderUpdate } from "@/services/api";
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 {
behavior: string;
ruleCount: number;
@@ -32,250 +38,153 @@ interface RuleProviderItem {
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 = () => {
const { t } = useTranslation();
const theme = useTheme();
const [open, setOpen] = useState(false);
const { ruleProviders, refreshRules, refreshRuleProviders } = useAppData();
const [updating, setUpdating] = useState<Record<string, boolean>>({});
// 检查是否有提供者
const hasProviders = Object.keys(ruleProviders || {}).length > 0;
const hasProviders = ruleProviders && Object.keys(ruleProviders).length > 0;
// 更新单个规则提供者
const updateProvider = useLockFn(async (name: string) => {
try {
// 设置更新状态
setUpdating((prev) => ({ ...prev, [name]: true }));
await ruleProviderUpdate(name);
// 刷新数据
await refreshRules();
await refreshRuleProviders();
showNotice("success", `${name} 更新成功`);
showNotice("success", `${name} ${t("Update Successful")}`);
} catch (err: any) {
showNotice(
"error",
`${name} 更新失败: ${err?.message || err.toString()}`,
`${name} ${t("Update Failed")}: ${err?.message || err.toString()}`,
);
} finally {
// 清除更新状态
setUpdating((prev) => ({ ...prev, [name]: false }));
}
});
// 更新所有规则提供者
const updateAllProviders = useLockFn(async () => {
try {
// 获取所有provider的名称
const allProviders = Object.keys(ruleProviders || {});
if (allProviders.length === 0) {
showNotice("info", "没有可更新的规则提供者");
return;
}
if (allProviders.length === 0) return;
// 设置所有provider为更新中状态
const newUpdating = allProviders.reduce(
(acc, key) => {
acc[key] = true;
return acc;
},
{} as Record<string, boolean>,
(acc, key) => ({ ...acc, [key]: true }),
{},
);
setUpdating(newUpdating);
// 改为串行逐个更新所有provider
for (const name of allProviders) {
try {
await ruleProviderUpdate(name);
// 每个更新完成后更新状态
setUpdating((prev) => ({ ...prev, [name]: false }));
} catch (err) {
console.error(`更新 ${name} 失败`, err);
// 继续执行下一个,不中断整体流程
console.error(`Failed to update ${name}`, err);
}
}
// 刷新数据
await refreshRules();
await refreshRuleProviders();
showNotice("success", "全部规则提供者更新成功");
} catch (err: any) {
showNotice("error", `更新失败: ${err?.message || err.toString()}`);
} finally {
// 清除所有更新状态
setUpdating({});
}
showNotice("success", t("All Rule Providers Updated"));
});
const handleClose = () => {
setOpen(false);
};
if (!hasProviders) return null;
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<StorageOutlined />}
onClick={() => setOpen(true)}
>
{t("Rule Provider")}
<Dialog open={open} onOpenChange={setOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="ghost" size="icon">
<Database className="h-5 w-5" />
</Button>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
>
<Typography variant="h6">{t("Rule Providers")}</Typography>
<Button
variant="contained"
size="small"
onClick={updateAllProviders}
>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>{t("Rule Provider")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DialogContent className="max-w-2xl">
<DialogHeader>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Убираем justify-between и используем gap для отступа */}
<div className="flex items-center gap-4">
<DialogTitle>{t("Rule Providers")}</DialogTitle>
<Button size="sm" onClick={updateAllProviders}>
{t("Update All")}
</Button>
</Box>
</DialogTitle>
</div>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</DialogHeader>
<DialogContent>
<List sx={{ py: 0, minHeight: 250 }}>
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6 py-4 space-y-2">
{Object.entries(ruleProviders || {}).map(([key, item]) => {
const provider = item as RuleProviderItem;
const time = dayjs(provider.updatedAt);
const isUpdating = updating[key];
return (
<ListItem
<div
key={key}
sx={[
{
p: 0,
mb: "8px",
borderRadius: 2,
overflow: "hidden",
transition: "all 0.2s",
},
({ palette: { mode, primary } }) => {
const bgcolor = mode === "light" ? "#ffffff" : "#24252f";
const hoverColor =
mode === "light"
? alpha(primary.main, 0.1)
: alpha(primary.main, 0.2);
className="flex items-center rounded-lg border bg-card p-3"
>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<p className="font-semibold truncate" title={key}>
{key}
</p>
<Badge variant="secondary">{provider.ruleCount}</Badge>
</div>
<p
className="text-xs text-muted-foreground"
title={time.format("YYYY-MM-DD HH:mm:ss")}
>
{t("Update At")}: {time.fromNow()}
</p>
</div>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline">{provider.vehicleType}</Badge>
<Badge variant="outline">{provider.behavior}</Badge>
</div>
</div>
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>
<Separator orientation="vertical" className="h-8 mx-4" />
<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"
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => updateProvider(key)}
disabled={isUpdating}
sx={{
animation: isUpdating
? "spin 1s linear infinite"
: "none",
"@keyframes spin": {
"0%": { transform: "rotate(0deg)" },
"100%": { transform: "rotate(360deg)" },
},
}}
title={t("Update Provider") as string}
>
<RefreshRounded />
</IconButton>
</Box>
</ListItem>
<RefreshCw
className={cn(
"h-5 w-5",
isUpdating && "animate-spin",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("Update Provider")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
})}
</List>
</DialogContent>
</div>
<DialogActions>
<Button onClick={handleClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
<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 }) => ({
display: "flex",
padding: "4px 16px",
color: theme.palette.text.primary,
}));
import { cn } from "@root/lib/utils"; // Импортируем утилиту для классов
const COLOR = [
"primary",
"secondary",
"info.main",
"warning.main",
"success.main",
// Массив CSS-классов для раскрашивания названий прокси
const PROXY_COLOR_CLASSES = [
"text-sky-500",
"text-violet-500",
"text-amber-500",
"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 {
index: number;
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 { index, value } = props;
return (
<Item sx={{ borderBottom: "1px solid var(--divider-color)" }}>
<Typography
color="text.secondary"
variant="body2"
sx={{ lineHeight: 2, minWidth: 30, mr: 2.25, textAlign: "center" }}
>
// Корневой элемент, стилизованный с помощью Tailwind
<div className="flex p-4 border-b border-border">
{/* Номер правила */}
<p className="w-10 text-center text-sm text-muted-foreground mr-4 pt-0.5">
{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 || "-"}
</Typography>
</p>
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ mr: 3, minWidth: 120, display: "inline-block" }}
>
{value.type}
</Typography>
<Typography
component="span"
variant="body2"
color={parseColor(value.proxy)}
>
{/* Нижняя строка с типом правила и названием прокси */}
<div className="flex items-center text-xs mt-1.5">
<p className="text-muted-foreground w-32 mr-4">{value.type}</p>
<p className={cn("font-medium", getProxyColorClass(value.proxy))}>
{value.proxy}
</Typography>
</Box>
</Item>
</p>
</div>
</div>
</div>
);
};

View File

@@ -4,18 +4,16 @@ import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { isValidUrl } from "@/utils/helper";
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 { 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 {
onBackupSuccess: () => Promise<void>;
@@ -26,93 +24,47 @@ export interface BackupConfigViewerProps {
}
export const BackupConfigViewer = memo(
({
onBackupSuccess,
onSaveSuccess,
onRefresh,
onInit,
setLoading,
}: BackupConfigViewerProps) => {
({ onBackupSuccess, onSaveSuccess, onRefresh, onInit, setLoading }: BackupConfigViewerProps) => {
const { t } = useTranslation();
const { verge } = useVerge();
const { webdav_url, webdav_username, webdav_password } = verge || {};
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>({
defaultValues: {
const form = useForm<IWebDavConfig>({
defaultValues: { url: '', username: '', password: '' },
});
// Синхронизируем форму с данными из verge
useEffect(() => {
form.reset({
url: webdav_url,
username: webdav_username,
password: webdav_password,
},
password: webdav_password
});
}, [webdav_url, webdav_username, webdav_password, form.reset]);
const { register, handleSubmit, watch, getValues } = form;
const url = watch("url");
const username = watch("username");
const password = watch("password");
const webdavChanged =
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 webdavChanged = webdav_url !== url || webdav_username !== username || webdav_password !== password;
const checkForm = () => {
const username = usernameRef.current?.value;
const password = passwordRef.current?.value;
const url = urlRef.current?.value;
if (!url) {
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 values = getValues();
if (!values.url) { showNotice("error", t("WebDAV URL Required")); throw new Error("URL Required"); }
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 (!values.password) { showNotice("error", t("Password Required")); throw new Error("Password Required"); }
};
const save = useLockFn(async (data: IWebDavConfig) => {
checkForm();
try { checkForm(); } catch { return; }
try {
setLoading(true);
await saveWebdavConfig(
data.url.trim(),
data.username.trim(),
data.password,
).then(() => {
await saveWebdavConfig(data.url.trim(), data.username.trim(), data.password);
showNotice("success", t("WebDAV Config Saved"));
onSaveSuccess();
});
await onSaveSuccess();
} catch (error) {
showNotice("error", t("WebDAV Config Save Failed", { error }), 3000);
} finally {
@@ -121,13 +73,12 @@ export const BackupConfigViewer = memo(
});
const handleBackup = useLockFn(async () => {
checkForm();
try { checkForm(); } catch { return; }
try {
setLoading(true);
await createWebdavBackup().then(async () => {
await createWebdavBackup();
showNotice("success", t("Backup Created"));
await onBackupSuccess();
});
} catch (error) {
showNotice("error", t("Backup Failed", { error }));
} finally {
@@ -136,109 +87,79 @@ export const BackupConfigViewer = memo(
});
return (
<form onSubmit={(e) => e.preventDefault()}>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 9 }}>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
label={t("WebDAV Server URL")}
variant="outlined"
size="small"
{...register("url")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={urlRef}
<Form {...form}>
<form onSubmit={e => e.preventDefault()} className="flex flex-col sm:flex-row gap-4">
{/* Левая часть: поля ввода */}
<div className="flex-1 space-y-4">
<FormField
control={form.control}
name="url"
render={({ field }) => (
<FormItem>
<FormLabel>{t("WebDAV Server URL")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<TextField
label={t("Username")}
variant="outlined"
size="small"
{...register("username")}
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
inputRef={usernameRef}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>{t("Username")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</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 ? (
<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
variant="contained"
color={"primary"}
sx={{ height: "100%" }}
type="button"
onClick={handleSubmit(save)}
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
variant="contained"
color="success"
onClick={handleBackup}
type="button"
size="large"
>
<Button type="button" className="w-full" onClick={handleBackup}>
{t("Backup")}
</Button>
<Button
variant="outlined"
onClick={onRefresh}
type="button"
size="large"
>
<Button type="button" variant="outline" className="w-full" onClick={onRefresh}>
{t("Refresh")}
</Button>
</>
)}
</Stack>
</Grid>
</Grid>
</div>
</form>
</Form>
);
},
}
);

View File

@@ -1,33 +1,28 @@
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 {
Box,
Paper,
IconButton,
Divider,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
TablePagination,
} from "@mui/material";
import { Typography } from "@mui/material";
import { useLockFn } from "ahooks";
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";
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Trash2, History } from "lucide-react";
export type BackupFile = IWebDavFile & {
platform: string;
backup_time: Dayjs;
backup_time: dayjs.Dayjs;
allow_apply: boolean;
};
@@ -36,154 +31,12 @@ export const DEFAULT_ROWS_PER_PAGE = 5;
export interface BackupTableViewerProps {
datasource: BackupFile[];
page: number;
onPageChange: (
event: React.MouseEvent<HTMLButtonElement> | null,
page: number,
) => void;
onPageChange: (event: React.MouseEvent<HTMLButtonElement> | null, page: number) => void;
total: number;
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>) {
return (
<svg
@@ -264,3 +117,120 @@ function MacIcon(props: SVGProps<SVGSVGElement>) {
</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 {
forwardRef,
useImperativeHandle,
useState,
useCallback,
useEffect,
} from "react";
import { forwardRef, useImperativeHandle, useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import getSystem from "@/utils/get-system";
import { BaseLoadingOverlay } from "@/components/base";
import dayjs from "dayjs";
import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import {
BackupTableViewer,
BackupFile,
DEFAULT_ROWS_PER_PAGE,
} from "./backup-table-viewer";
import { BackupConfigViewer } from "./backup-config-viewer";
import { Box, Paper, Divider } from "@mui/material";
import { useLockFn } from "ahooks";
// Новые импорты
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);
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
const [dataSource, setDataSource] = useState<BackupFile[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const OS = getSystem();
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
open: () => setOpen(true),
close: () => setOpen(false),
}));
// Handle page change
const handleChangePage = useCallback(
(_: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
setPage(page);
(_: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
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 {
setIsLoading(true);
const files = await getAllBackupFiles();
@@ -61,35 +72,10 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
setBackupFiles([]);
setTotal(0);
console.error(error);
// Notice.error(t("Failed to fetch backup files"));
} finally {
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(() => {
setDataSource(
@@ -101,35 +87,26 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
}, [page, backupFiles]);
return (
<BaseDialog
open={open}
title={t("Backup Setting")}
// contentSx={{ width: 600, maxHeight: 800 }}
okBtn={t("")}
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
disableOk
>
<Box>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t("Backup Setting")}</DialogTitle>
</DialogHeader>
{/* Основной контейнер с relative для оверлея загрузки */}
<div className="relative space-y-4">
<BaseLoadingOverlay isLoading={isLoading} />
<Paper elevation={2} sx={{ padding: 2 }}>
<BackupConfigViewer
setLoading={setIsLoading}
onBackupSuccess={async () => {
fetchAndSetBackupFiles();
}}
onSaveSuccess={async () => {
fetchAndSetBackupFiles();
}}
onRefresh={async () => {
fetchAndSetBackupFiles();
}}
onInit={async () => {
fetchAndSetBackupFiles();
}}
onBackupSuccess={fetchAndSetBackupFiles}
onSaveSuccess={fetchAndSetBackupFiles}
onRefresh={fetchAndSetBackupFiles}
onInit={fetchAndSetBackupFiles}
/>
<Divider sx={{ marginY: 2 }} />
<Separator />
<BackupTableViewer
datasource={dataSource}
page={page}
@@ -137,8 +114,14 @@ export const BackupViewer = forwardRef<DialogRef>((props, ref) => {
total={total}
onRefresh={fetchAndSetBackupFiles}
/>
</Paper>
</Box>
</BaseDialog>
</div>
<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 { BaseDialog, DialogRef } from "@/components/base";
import { useTranslation } from "react-i18next";
import { useVerge } from "@/hooks/use-verge";
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 {
SwitchAccessShortcutRounded,
RestartAltRounded,
} from "@mui/icons-material";
import {
Box,
Chip,
CircularProgress,
List,
ListItemButton,
ListItemText,
} from "@mui/material";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Loader2, Replace, RotateCw } from "lucide-react";
import { cn } from "@root/lib/utils";
// Логика и сервисы
import { useVerge } from "@/hooks/use-verge";
import { changeClashCore, restartCore } from "@/services/cmds";
import { closeAllConnections, upgradeCore } from "@/services/api";
import { showNotice } from "@/services/noticeService";
// Константы и интерфейсы
const VALID_CORE = [
{ name: "Mihomo", core: "verge-mihomo", chip: "Release 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) => {
const { t } = useTranslation();
const { verge, mutateVerge } = useVerge();
const [open, setOpen] = useState(false);
@@ -45,18 +49,15 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
const onCoreChange = useLockFn(async (core: string) => {
if (core === clash_core) return;
try {
setChangingCore(core);
closeAllConnections();
const errorMsg = await changeClashCore(core);
if (errorMsg) {
showNotice("error", errorMsg);
setChangingCore(null);
return;
}
mutateVerge();
setTimeout(() => {
mutate("getClashConfig");
@@ -74,10 +75,10 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
setRestarting(true);
await restartCore();
showNotice("success", t(`Clash Core Restarted`));
setRestarting(false);
} catch (err: any) {
setRestarting(false);
showNotice("error", err.message || err.toString());
} finally {
setRestarting(false);
}
});
@@ -85,81 +86,79 @@ export const ClashCoreViewer = forwardRef<DialogRef>((props, ref) => {
try {
setUpgrading(true);
await upgradeCore();
setUpgrading(false);
showNotice("success", t(`Core Version Updated`));
} catch (err: any) {
setUpgrading(false);
const errMsg = err.response?.data?.message || err.toString();
const showMsg = errMsg.includes("already using latest version")
? "Already Using Latest Core Version"
: errMsg;
showNotice("error", t(showMsg));
} finally {
setUpgrading(false);
}
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Clash Core")}
<Box>
<LoadingButton
variant="contained"
size="small"
startIcon={<SwitchAccessShortcutRounded />}
loadingPosition="start"
loading={upgrading}
disabled={restarting || changingCore !== null}
sx={{ marginRight: "8px" }}
onClick={onUpgrade}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Добавляем отступ справа (pr-12), чтобы освободить место для крестика */}
<DialogHeader className="pr-12">
<div className="flex justify-between items-center">
<DialogTitle>{t("Clash Core")}</DialogTitle>
<div className="flex items-center gap-2">
<Button size="sm" disabled={restarting || changingCore !== null} onClick={onUpgrade}>
{upgrading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Replace className="mr-2 h-4 w-4" />}
{t("Upgrade")}
</LoadingButton>
<LoadingButton
variant="contained"
size="small"
startIcon={<RestartAltRounded />}
loadingPosition="start"
loading={restarting}
disabled={upgrading}
onClick={onRestart}
>
</Button>
<Button size="sm" disabled={upgrading || changingCore !== null} onClick={onRestart}>
{restarting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RotateCw className="mr-2 h-4 w-4" />}
{t("Restart")}
</LoadingButton>
</Box>
</Box>
}
contentSx={{
pb: 0,
width: 400,
height: 180,
overflowY: "auto",
userSelect: "text",
marginTop: "-8px",
}}
disableOk
cancelBtn={t("Close")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
>
<List component="nav">
{VALID_CORE.map((each) => (
<ListItemButton
</Button>
</div>
</div>
</DialogHeader>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
<div className="space-y-2 py-4">
{VALID_CORE.map((each) => {
const isSelected = each.core === clash_core;
const isChanging = changingCore === each.core;
const isDisabled = changingCore !== null || restarting || upgrading;
return (
<div
key={each.core}
selected={each.core === clash_core}
onClick={() => onCoreChange(each.core)}
disabled={changingCore !== null || restarting || upgrading}
>
<ListItemText primary={each.name} secondary={`/${each.core}`} />
{changingCore === each.core ? (
<CircularProgress size={20} sx={{ mr: 1 }} />
) : (
<Chip label={t(`${each.chip}`)} size="small" />
data-selected={isSelected}
onClick={() => !isDisabled && onCoreChange(each.core)}
className={cn(
"flex items-center justify-between p-3 rounded-md transition-colors",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-accent",
isSelected && "bg-accent"
)}
</ListItemButton>
))}
</List>
</BaseDialog>
>
<div>
<p className="font-semibold text-sm">{each.name}</p>
<p className="text-xs text-muted-foreground">{`/${each.core}`}</p>
</div>
<div className="w-28 text-right flex justify-end">
{isChanging ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Badge variant={isSelected ? "default" : "secondary"}>{t(each.chip)}</Badge>
)}
</div>
</div>
);
})}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

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 { useVerge } from "@/hooks/use-verge";
import { showNotice } from "@/services/noticeService";
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 {
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
Stack,
TextField,
} from "@mui/material";
import { useLockFn, useRequest } from "ahooks";
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Shuffle, Loader2 } from "lucide-react";
const OS = getSystem();
interface ClashPortViewerProps {}
interface ClashPortViewerRef {
open: () => void;
close: () => void;
}
const generateRandomPort = () =>
Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
const generateRandomPort = () => Math.floor(Math.random() * (65535 - 1025 + 1)) + 1025;
export const ClashPortViewer = forwardRef<
ClashPortViewerRef,
ClashPortViewerProps
>((props, ref) => {
// Компонент для одной строки настроек порта
const PortSettingRow = ({
label,
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 { clashInfo, patchInfo } = useClashInfo();
const { verge, patchVerge } = useVerge();
const [open, setOpen] = useState(false);
// Mixed Port
const [mixedPort, setMixedPort] = useState(
verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897,
);
const [mixedPort, setMixedPort] = useState(0);
const [socksPort, setSocksPort] = useState(0);
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(
async (params: { clashConfig: any; vergeConfig: any }) => {
const { clashConfig, vergeConfig } = params;
@@ -73,24 +124,24 @@ export const ClashPortViewer = forwardRef<
manual: true,
onSuccess: () => {
setOpen(false);
showNotice("success", t("Port settings saved")); // 调用提示函数
showNotice("success", t("Port settings saved"));
},
onError: () => {
showNotice("error", t("Failed to save settings")); // 调用提示函数
showNotice("error", t("Failed to save settings"));
},
},
);
useImperativeHandle(ref, () => ({
open: () => {
setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7897);
setSocksPort(verge?.verge_socks_port ?? 7898);
setMixedPort(verge?.verge_mixed_port ?? clashInfo?.mixed_port ?? 7890);
setSocksPort(verge?.verge_socks_port ?? 7891);
setSocksEnabled(verge?.verge_socks_enabled ?? false);
setHttpPort(verge?.verge_port ?? 7899);
setHttpPort(verge?.verge_port ?? 7892);
setHttpEnabled(verge?.verge_http_enabled ?? false);
setRedirPort(verge?.verge_redir_port ?? 7895);
setRedirPort(verge?.verge_redir_port ?? 7893);
setRedirEnabled(verge?.verge_redir_enabled ?? false);
setTproxyPort(verge?.verge_tproxy_port ?? 7896);
setTproxyPort(verge?.verge_tproxy_port ?? 7894);
setTproxyEnabled(verge?.verge_tproxy_enabled ?? false);
setOpen(true);
},
@@ -98,40 +149,31 @@ export const ClashPortViewer = forwardRef<
}));
const onSave = useLockFn(async () => {
// 端口冲突检测
const portList = [
mixedPort,
socksEnabled ? socksPort : -1,
httpEnabled ? httpPort : -1,
redirEnabled ? redirPort : -1,
tproxyEnabled ? tproxyPort : -1,
].filter((p) => p !== -1);
].filter((p) => p > 0);
if (new Set(portList).size !== portList.length) {
showNotice("error", t("Port conflict detected"));
return;
}
// 验证端口范围
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));
const allPortsValid = portList.every((port) => port >= 1 && port <= 65535);
if (!allPortsValid) {
showNotice("error", t("Port out of range (1-65535)"));
return;
}
// 准备配置数据
const clashConfig = {
"mixed-port": mixedPort,
"socks-port": socksPort,
port: httpPort,
"redir-port": redirPort,
"tproxy-port": tproxyPort,
"socks-port": socksEnabled ? socksPort : 0,
"port": httpEnabled ? httpPort : 0,
"redir-port": redirEnabled ? redirPort : 0,
"tproxy-port": tproxyEnabled ? tproxyPort : 0,
};
const vergeConfig = {
@@ -146,221 +188,36 @@ export const ClashPortViewer = forwardRef<
verge_tproxy_enabled: tproxyEnabled,
};
// 提交保存请求
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 (
<BaseDialog
open={open}
title={t("Port Configuration")}
contentSx={{
width: 400,
}}
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 }}>
<ListItemText
primary={t("Socks Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
value={socksPort}
onChange={(e) =>
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 }}>
<ListItemText
primary={t("Http Port")}
primaryTypographyProps={{ fontSize: 12 }}
/>
<div style={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
sx={{ width: 80, mr: 0.5, fontSize: 12 }}
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>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Port Configuration")}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-1">
<PortSettingRow label={t("Mixed Port")} port={mixedPort} setPort={setMixedPort} isEnabled={true} isFixed={true} />
<PortSettingRow label={t("Socks Port")} port={socksPort} setPort={setSocksPort} isEnabled={socksEnabled} setIsEnabled={setSocksEnabled} />
<PortSettingRow label={t("Http Port")} port={httpPort} setPort={setHttpPort} isEnabled={httpEnabled} setIsEnabled={setHttpEnabled} />
{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>
<PortSettingRow label={t("Redir Port")} port={redirPort} setPort={setRedirPort} isEnabled={redirEnabled} setIsEnabled={setRedirEnabled} />
)}
{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>
<PortSettingRow label={t("Tproxy Port")} port={tproxyPort} setPort={setTproxyPort} isEnabled={tproxyEnabled} setIsEnabled={setTproxyEnabled} />
)}
</List>
</BaseDialog>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -1,15 +1,18 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { Box, Chip } from "@mui/material";
import { getRuntimeYaml } from "@/services/cmds";
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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [runtimeConfig, setRuntimeConfig] = useState("");
// useImperativeHandle остается без изменений
useImperativeHandle(ref, () => ({
open: () => {
getRuntimeYaml().then((data) => {
@@ -21,14 +24,18 @@ export const ConfigViewer = forwardRef<DialogRef>((_, ref) => {
}));
if (!open) return null;
return (
<EditorViewer
open={true}
title={
<Box display="flex" alignItems="center" gap={2}>
{t("Runtime Config")}
<Chip label={t("ReadOnly")} size="small" />
</Box>
// --- НАЧАЛО ИЗМЕНЕНИЙ ---
// Заменяем Box на div и Chip на Badge
<div className="flex items-center gap-2">
<span>{t("Runtime Config")}</span>
<Badge variant="secondary">{t("ReadOnly")}</Badge>
</div>
// --- КОНЕЦ ИЗМЕНЕНИЙ ---
}
initialData={Promise.resolve(runtimeConfig)}
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 { 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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [copySuccess, setCopySuccess] = useState<null | string>(null);
const [isSaving, setIsSaving] = useState(false);
const { clashInfo, patchInfo } = useClashInfo();
const [controller, setController] = useState(clashInfo?.server || "");
const [secret, setSecret] = useState(clashInfo?.secret || "");
const [controller, setController] = useState("");
const [secret, setSecret] = useState("");
// 对话框打开时初始化配置
useImperativeHandle(ref, () => ({
open: async () => {
setOpen(true);
setController(clashInfo?.server || "");
setSecret(clashInfo?.secret || "");
setOpen(true);
},
close: () => setOpen(false),
}));
// 保存配置
const onSave = useLockFn(async () => {
if (!controller.trim()) {
showNotice("error", t("Controller address cannot be empty"));
return;
}
if (!secret.trim()) {
showNotice("error", t("Secret cannot be empty"));
return;
}
// Секрет может быть пустым
// if (!secret.trim()) {
// showNotice("error", t("Secret cannot be empty"));
// return;
// }
try {
setIsSaving(true);
await patchInfo({ "external-controller": controller, secret });
showNotice("success", t("Configuration saved successfully"));
setOpen(false);
} catch (err: any) {
showNotice(
"error",
err.message || t("Failed to save configuration"),
4000,
);
showNotice("error", err.message || t("Failed to save configuration"), 4000);
} finally {
setIsSaving(false);
}
});
// 复制到剪贴板
const handleCopyToClipboard = useLockFn(
async (text: string, type: string) => {
const handleCopyToClipboard = useLockFn(async (text: string, type: string) => {
try {
await navigator.clipboard.writeText(text);
setCopySuccess(type);
setTimeout(() => setCopySuccess(null));
// --- ИЗМЕНЕНИЕ: Используем showNotice вместо Snackbar ---
const message = type === "controller"
? t("Controller address copied to clipboard")
: t("Secret copied to clipboard");
showNotice("success", message);
} catch (err) {
showNotice("error", t("Failed to copy"));
}
},
);
});
return (
<BaseDialog
open={open}
title={t("External Controller")}
contentSx={{ width: 400 }}
okBtn={
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",
}}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("External Controller")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="controller-address">{t("External Controller")}</Label>
<div className="flex items-center gap-2">
<Input
id="controller-address"
value={controller}
placeholder="Required"
placeholder="127.0.0.1:9090"
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>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => handleCopyToClipboard(controller, "controller")} disabled={isSaving}>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent><p>{t("Copy to clipboard")}</p></TooltipContent>
</Tooltip>
</Box>
</ListItem>
</TooltipProvider>
</div>
</div>
<ListItem
sx={{
padding: "5px 2px",
display: "flex",
justifyContent: "space-between",
}}
>
<ListItemText primary={t("Core Secret")} />
<Box display="flex" alignItems="center" gap={1}>
<TextField
size="small"
sx={{
width: 175,
opacity: 1,
pointerEvents: "auto",
}}
<div className="grid gap-2">
<Label htmlFor="core-secret">{t("Core Secret")}</Label>
<div className="flex items-center gap-2">
<Input
id="core-secret"
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}
>
<ContentCopy fontSize="small" />
</IconButton>
<TooltipProvider>
<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>
</Box>
</ListItem>
</List>
</TooltipProvider>
</div>
</div>
</div>
<Snackbar
open={copySuccess !== null}
autoHideDuration={2000}
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
>
<Alert severity="success">
{copySuccess === "controller"
? t("Controller address copied to clipboard")
: t("Secret copied to clipboard")}
</Alert>
</Snackbar>
</BaseDialog>
<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 { 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 { parseHotkey } from "@/utils/parse-hotkey";
import { cn } from "@root/lib/utils";
const KeyWrapper = styled("div")(({ theme }) => ({
position: "relative",
width: 165,
minHeight: 36,
// Новые импорты
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
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 {
value: string[];
@@ -63,55 +22,66 @@ export const HotkeyInput = (props: Props) => {
const changeRef = useRef<string[]>([]);
const [keys, setKeys] = useState(value);
return (
<Box sx={{ display: "flex", alignItems: "center" }}>
<KeyWrapper>
<input
onKeyUp={() => {
const handleKeyUp = () => {
const ret = changeRef.current.slice();
if (ret.length) {
onChange(ret);
changeRef.current = [];
}
}}
onKeyDown={(e) => {
const evt = e.nativeEvent;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.preventDefault();
e.stopPropagation();
const key = parseHotkey(evt.key);
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Передаем e.key (строку), а не e.nativeEvent (объект)
const key = parseHotkey(e.key);
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
if (key === "UNIDENTIFIED") return;
changeRef.current = [...new Set([...changeRef.current, key])];
setKeys(changeRef.current);
}}
/>
};
<div className="list">
{keys.map((key, index) => (
<Box display="flex">
<span className="delimiter" hidden={index === 0}>
+
</span>
<div key={key} className="item">
{key}
</div>
</Box>
))}
</div>
</KeyWrapper>
<IconButton
size="small"
title={t("Delete")}
color="inherit"
onClick={() => {
const handleClear = () => {
onChange([]);
setKeys([]);
}}
changeRef.current = [];
};
return (
<div className="flex items-center gap-2">
<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
readOnly
onKeyUp={handleKeyUp}
onKeyDown={handleKeyDown}
className="absolute inset-0 z-10 h-full w-full cursor-text opacity-0"
/>
<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">
{keys && keys.length > 0 ? (
keys.map((key) => (
<Badge key={key} variant="secondary">
{key}
</Badge>
))
) : (
<span className="text-muted-foreground">{t("Press any key")}</span>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t("Delete")}
onClick={handleClear}
>
<DeleteRounded fontSize="inherit" />
</IconButton>
</Box>
<X className="h-4 w-4" />
</Button>
</div>
);
};

View File

@@ -1,18 +1,24 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLockFn } from "ahooks";
import { styled, Typography, Switch } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog, DialogRef } from "@/components/base";
import { HotkeyInput } from "./hotkey-input";
import { showNotice } from "@/services/noticeService";
const ItemWrapper = styled("div")`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
`;
// Новые импорты
import { DialogRef } from "@/components/base";
import { HotkeyInput } from "./hotkey-input"; // Наш обновленный компонент
import { Switch } from "@/components/ui/switch"; // Стандартный Switch
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 = [
"open_or_close_dashboard",
@@ -31,27 +37,18 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const { verge, patchVerge } = useVerge();
const [hotkeyMap, setHotkeyMap] = useState<Record<string, string[]>>({});
const [enableGlobalHotkey, setEnableHotkey] = useState(
verge?.enable_global_hotkey ?? true,
);
const [enableGlobalHotkey, setEnableHotkey] = useState(true);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
setEnableHotkey(verge?.enable_global_hotkey ?? true);
const map = {} as typeof hotkeyMap;
verge?.hotkeys?.forEach((text) => {
const [func, key] = text.split(",").map((e) => e.trim());
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);
},
close: () => setOpen(false),
@@ -61,13 +58,7 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
const hotkeys = Object.entries(hotkeyMap)
.map(([func, keys]) => {
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 "";
return `${func},${key}`;
})
@@ -79,40 +70,51 @@ export const HotkeyViewer = forwardRef<DialogRef>((props, ref) => {
enable_global_hotkey: enableGlobalHotkey,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
return (
<BaseDialog
open={open}
title={t("Hotkey Setting")}
contentSx={{ width: 450, maxHeight: 380 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<ItemWrapper style={{ marginBottom: 16 }}>
<Typography>{t("Enable Global Hotkey")}</Typography>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Hotkey Setting")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center justify-between">
<Label htmlFor="enable-global-hotkey" className="font-medium">
{t("Enable Global Hotkey")}
</Label>
<Switch
edge="end"
id="enable-global-hotkey"
checked={enableGlobalHotkey}
onChange={(e) => setEnableHotkey(e.target.checked)}
onCheckedChange={setEnableHotkey}
/>
</ItemWrapper>
</div>
<Separator />
{HOTKEY_FUNC.map((func) => (
<ItemWrapper key={func}>
<Typography>{t(func)}</Typography>
<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 }))}
/>
</ItemWrapper>
</div>
))}
</BaseDialog>
</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 {
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 { useLockFn } from "ahooks";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
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 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 { 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 getIcons = async (icon_dir: string, name: string) => {
const updateTime = localStorage.getItem(`icon_${name}_update_time`) || "";
const icon_png = await join(icon_dir, `${name}-${updateTime}.png`);
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) => {
const { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
@@ -45,42 +44,21 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
const [sysproxyIcon, setSysproxyIcon] = useState("");
const [tunIcon, setTunIcon] = useState("");
useEffect(() => {
initIconPath();
const initIconPath = useCallback(async () => {
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() {
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",
);
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);
}
}
useEffect(() => {
if (open) initIconPath();
}, [open, initIconPath]);
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
@@ -95,298 +73,129 @@ export const LayoutViewer = forwardRef<DialogRef>((props, ref) => {
mutateVerge({ ...verge, ...patch }, false);
};
return (
<BaseDialog
open={open}
title={t("Layout Setting")}
contentSx={{ width: 450 }}
disableOk
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>
<ListItemText primary={t("Memory Usage")} />
<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 })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item>
<ListItemText primary={t("Proxy Group Icon")} />
<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 })}
>
<Switch edge="end" />
</GuardState>
</Item>
<Item>
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
<span>{t("Hover Jump Navigator")}</span>
<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>
<ListItemText primary={t("Nav Icon")} />
<GuardState
value={verge?.menu_icon ?? "monochrome"}
onCatch={onError}
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" && (
<Item>
<ListItemText primary={t("Tray Icon")} />
<GuardState
value={verge?.tray_icon ?? "monochrome"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ tray_icon: e })}
onGuard={(e) => patchVerge({ tray_icon: e })}
>
<Select
size="small"
sx={{ width: 140, "> div": { py: "7.5px" } }}
>
<MenuItem value="monochrome">{t("Monochrome")}</MenuItem>
<MenuItem value="colorful">{t("Colorful")}</MenuItem>
</Select>
</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>
<ListItemText primary={t("Common Tray Icon")} />
<GuardState
value={verge?.common_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ common_tray_icon: e })}
onGuard={(e) => patchVerge({ common_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.common_tray_icon &&
commonIcon && (
<img height="20px" src={convertFileSrc(commonIcon)} />
)
}
onClick={async () => {
if (verge?.common_tray_icon) {
onChangeData({ common_tray_icon: false });
patchVerge({ common_tray_icon: 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"],
},
],
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 });
}
}
});
if (selected) {
await copyIconFile(`${selected}`, "common");
await initIconPath();
onChangeData({ common_tray_icon: true });
patchVerge({ common_tray_icon: true });
console.log();
}
}
}}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Layout Setting")}</DialogTitle>
</DialogHeader>
<div className="py-4 space-y-1">
<SettingRow label={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 />
</GuardState>
</SettingRow>
<SettingRow label={t("Memory Usage")}>
<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 })}>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Proxy Group Icon")}>
<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 })}>
<Switch />
</GuardState>
</SettingRow>
<SettingRow label={t("Hover Jump Navigator")} extra={<TooltipIcon tooltip={t("Hover Jump Navigator Info")} />}>
<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 />
</GuardState>
</SettingRow>
<SettingRow label={t("Nav Icon")}>
<GuardState value={verge?.menu_icon ?? "monochrome"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ menu_icon: e })} onGuard={(e) => patchVerge({ menu_icon: e })}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 1 --- */}
<Select
onValueChange={(value) => onChangeData({ menu_icon: value as any })}
value={verge?.menu_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 1 --- */}
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
{OS === "macos" && (
<>
<SettingRow label={t("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 })}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2 --- */}
<Select
onValueChange={(value) => onChangeData({ tray_icon: value as any })}
value={verge?.tray_icon}
>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="monochrome">{t("Monochrome")}</SelectItem>
<SelectItem value="colorful">{t("Colorful")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingRow>
<SettingRow label={t("Enable Tray Icon")}>
<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 })}>
<Switch />
</GuardState>
</SettingRow>
</>
)}
<SettingRow label={t("Common Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('common')}>
{verge?.common_tray_icon && commonIcon && <img src={convertFileSrc(commonIcon)} className="h-5 mr-2" alt="common tray icon" />}
{verge?.common_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
</SettingRow>
<Item>
<ListItemText primary={t("System Proxy Tray Icon")} />
<GuardState
value={verge?.sysproxy_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ sysproxy_tray_icon: e })}
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 });
}
}
}}
>
<SettingRow label={t("System Proxy Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('sysproxy')}>
{verge?.sysproxy_tray_icon && sysproxyIcon && <img src={convertFileSrc(sysproxyIcon)} className="h-5 mr-2" alt="system proxy tray icon"/>}
{verge?.sysproxy_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
</SettingRow>
<Item>
<ListItemText primary={t("Tun Tray Icon")} />
<GuardState
value={verge?.tun_tray_icon}
onCatch={onError}
onChange={(e) => onChangeData({ tun_tray_icon: e })}
onGuard={(e) => patchVerge({ tun_tray_icon: e })}
>
<Button
variant="outlined"
size="small"
startIcon={
verge?.tun_tray_icon &&
tunIcon && <img height="20px" src={convertFileSrc(tunIcon)} />
}
onClick={async () => {
if (verge?.tun_tray_icon) {
onChangeData({ tun_tray_icon: false });
patchVerge({ tun_tray_icon: false });
} else {
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 });
}
}
}}
>
<SettingRow label={t("Tun Tray Icon")}>
<Button variant="outline" size="sm" className="h-8" onClick={() => handleIconChange('tun')}>
{verge?.tun_tray_icon && tunIcon && <img src={convertFileSrc(tunIcon)} className="h-5 mr-2" alt="tun mode tray icon"/>}
{verge?.tun_tray_icon ? t("Clear") : t("Browse")}
</Button>
</GuardState>
</Item>
</List>
</BaseDialog>
</SettingRow>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Close")}</Button></DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

@@ -1,20 +1,38 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
TextField,
Typography,
InputAdornment,
} 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 { entry_lightweight_mode } from "@/services/cmds";
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) => {
const { t } = useTranslation();
const { verge, patchVerge } = useVerge();
@@ -22,7 +40,7 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
autoEnterLiteMode: false,
autoEnterLiteModeDelay: 10, // 默认10分钟
autoEnterLiteModeDelay: 10,
});
useImperativeHandle(ref, () => ({
@@ -43,69 +61,46 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
auto_light_weight_minutes: values.autoEnterLiteModeDelay,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.message || err.toString());
}
});
return (
<BaseDialog
open={open}
title={t("LightWeight Mode Settings")}
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("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>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("LightWeight Mode Settings")}</DialogTitle>
</DialogHeader>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Auto Enter LightWeight Mode")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Auto Enter LightWeight Mode Info")}
sx={{ opacity: "0.7" }}
/>
<div className="py-4 space-y-2">
<SettingRow label={t("Enter LightWeight Mode Now")}>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем variant="link" на "outline" для вида кнопки */}
<Button variant="outline" size="sm" onClick={entry_lightweight_mode}>
{t("Enable")}
</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</SettingRow>
<SettingRow
label={t("Auto Enter LightWeight Mode")}
extra={<TooltipIcon tooltip={t("Auto Enter LightWeight Mode Info")} />}
>
<Switch
edge="end"
checked={values.autoEnterLiteMode}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoEnterLiteMode: c }))
}
sx={{ marginLeft: "auto" }}
onCheckedChange={(c) => setValues((v) => ({ ...v, autoEnterLiteMode: c }))}
/>
</ListItem>
</SettingRow>
{values.autoEnterLiteMode && (
<>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Enter LightWeight Mode Delay")} />
<TextField
autoComplete="off"
size="small"
<div className="pl-4">
<SettingRow label={t("Auto Enter LightWeight Mode Delay")}>
<div className="flex items-center gap-2">
<Input
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 150 }}
className="w-24 h-8"
value={values.autoEnterLiteModeDelay}
onChange={(e) =>
setValues((v) => ({
@@ -113,33 +108,26 @@ export const LiteModeViewer = forwardRef<DialogRef>((props, ref) => {
autoEnterLiteModeDelay: parseInt(e.target.value) || 1,
}))
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">
{t("mins")}
</InputAdornment>
),
},
}}
/>
</ListItem>
<span className="text-sm text-muted-foreground">{t("mins")}</span>
</div>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontStyle: "italic" }}
>
<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 },
{ n: values.autoEnterLiteModeDelay }
)}
</Typography>
</ListItem>
</>
</p>
</div>
)}
</List>
</BaseDialog>
</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 { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
MenuItem,
Select,
TextField,
InputAdornment,
} from "@mui/material";
// Новые импорты
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 { 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) => {
const { t } = useTranslation();
@@ -51,206 +72,110 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const onSave = useLockFn(async () => {
try {
await patchVerge({
app_log_level: values.appLogLevel,
app_log_level: values.appLogLevel as any,
auto_close_connection: values.autoCloseConnection,
auto_check_update: values.autoCheckUpdate,
enable_builtin_enhanced: values.enableBuiltinEnhanced,
proxy_layout_column: values.proxyLayoutColumn,
proxy_layout_column: Number(values.proxyLayoutColumn),
default_latency_test: values.defaultLatencyTest,
default_latency_timeout: values.defaultLatencyTimeout,
default_latency_timeout: Number(values.defaultLatencyTimeout),
auto_log_clean: values.autoLogClean as any,
});
setOpen(false);
showNotice("success", t("Saved Successfully"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
const handleValueChange = (key: keyof typeof values, value: any) => {
setValues(v => ({ ...v, [key]: value }));
};
return (
<BaseDialog
open={open}
title={t("Miscellaneous")}
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("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,
}))
}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("Miscellaneous")}</DialogTitle>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto px-1 space-y-1">
<SettingRow label={<LabelWithIcon icon={FileText} text={t("App Log Level")} />}>
<Select value={values.appLogLevel} onValueChange={(v) => handleValueChange("appLogLevel", v)}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{["trace", "debug", "info", "warn", "error", "silent"].map((i) => (
<MenuItem value={i} key={i}>
{i[0].toUpperCase() + i.slice(1).toLowerCase()}
</MenuItem>
<SelectItem value={i} key={i}>{i[0].toUpperCase() + i.slice(1).toLowerCase()}</SelectItem>
))}
</SelectContent>
</Select>
</ListItem>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Auto Close Connections")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Auto Close Connections Info")}
sx={{ opacity: "0.7" }}
/>
<Switch
edge="end"
checked={values.autoCloseConnection}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCloseConnection: c }))
}
sx={{ marginLeft: "auto" }}
/>
</ListItem>
<SettingRow label={<LabelWithIcon icon={Unplug} text={t("Auto Close Connections")} />} extra={<TooltipIcon tooltip={t("Auto Close Connections Info")} />}>
<Switch checked={values.autoCloseConnection} onCheckedChange={(c) => handleValueChange("autoCloseConnection", c)} />
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Check Update")} />
<Switch
edge="end"
checked={values.autoCheckUpdate}
onChange={(_, c) =>
setValues((v) => ({ ...v, autoCheckUpdate: c }))
}
/>
</ListItem>
<SettingRow label={<LabelWithIcon icon={RefreshCw} text={t("Auto Check Update")} />}>
<Switch checked={values.autoCheckUpdate} onCheckedChange={(c) => handleValueChange("autoCheckUpdate", c)} />
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Enable Builtin Enhanced")}
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>
<SettingRow label={<LabelWithIcon icon={Zap} text={t("Enable Builtin Enhanced")} />} extra={<TooltipIcon tooltip={t("Enable Builtin Enhanced Info")} />}>
<Switch checked={values.enableBuiltinEnhanced} onCheckedChange={(c) => handleValueChange("enableBuiltinEnhanced", c)} />
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Proxy Layout Columns")} />
<Select
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>
))}
<SettingRow label={<LabelWithIcon icon={Columns} text={t("Proxy Layout Columns")} />}>
<Select value={String(values.proxyLayoutColumn)} onValueChange={(v) => handleValueChange("proxyLayoutColumn", Number(v))}>
<SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="6">{t("Auto Columns")}</SelectItem>
{[1, 2, 3, 4, 5].map((i) => (<SelectItem value={String(i)} key={i}>{i}</SelectItem>))}
</SelectContent>
</Select>
</ListItem>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Log Clean")} />
<Select
size="small"
sx={{ width: 135, "> div": { py: "7.5px" } }}
value={values.autoLogClean}
onChange={(e) =>
setValues((v) => ({
...v,
autoLogClean: e.target.value as number,
}))
}
>
<SettingRow label={<LabelWithIcon icon={ArchiveRestore} text={t("Auto Log Clean")} />}>
<Select value={String(values.autoLogClean)} onValueChange={(v) => handleValueChange("autoLogClean", Number(v))}>
<SelectTrigger className="w-48 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[
{ 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>
))}
].map((i) => (<SelectItem key={i.value} value={String(i.value)}>{i.key}</SelectItem>))}
</SelectContent>
</Select>
</ListItem>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Default Latency Test")}
sx={{ maxWidth: "fit-content" }}
/>
<TooltipIcon
title={t("Default Latency Test Info")}
sx={{ opacity: "0.7" }}
/>
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250, marginLeft: "auto" }}
<SettingRow label={<LabelWithIcon icon={LinkIcon} text={t("Default Latency Test")} />} extra={<TooltipIcon tooltip={t("Default Latency Test Info")} />}>
<Input
className="w-75 h-8"
value={values.defaultLatencyTest}
placeholder="https://cp.cloudflare.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}
placeholder="https://www.google.com/generate_204"
onChange={(e) => handleValueChange("defaultLatencyTest", e.target.value)}
/>
</ListItem>
</SettingRow>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Default Latency Timeout")} />
<TextField
autoComplete="new-password"
size="small"
<SettingRow label={<LabelWithIcon icon={Timer} text={t("Default Latency Timeout")} />}>
<div className="flex items-center gap-2">
<Input
type="number"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
className="w-24 h-8"
value={values.defaultLatencyTimeout}
placeholder="10000"
onChange={(e) =>
setValues((v) => ({
...v,
defaultLatencyTimeout: parseInt(e.target.value),
}))
}
slotProps={{
input: {
endAdornment: (
<InputAdornment position="end">{t("millis")}</InputAdornment>
),
},
}}
placeholder="5000"
onChange={(e) => handleValueChange("defaultLatencyTimeout", Number(e.target.value))}
/>
</ListItem>
</List>
</BaseDialog>
<span className="text-sm text-muted-foreground">{t("millis")}</span>
</div>
</SettingRow>
</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,140 +1,117 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { BaseDialog, DialogRef } from "@/components/base";
import { getNetworkInterfacesInfo } from "@/services/cmds";
import { alpha, Box, Button, IconButton } from "@mui/material";
import { ContentCopyRounded } from "@mui/icons-material";
import { useLockFn } from "ahooks";
import useSWR from "swr";
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
// Новые импорты
import { getNetworkInterfacesInfo } from "@/services/cmds";
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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [networkInterfaces, setNetworkInterfaces] = useState<
INetworkInterface[]
>([]);
const [isV4, setIsV4] = useState(true);
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true);
},
open: () => setOpen(true),
close: () => setOpen(false),
}));
useEffect(() => {
if (!open) return;
getNetworkInterfacesInfo().then((res) => {
setNetworkInterfaces(res);
});
}, [open]);
const { data: networkInterfaces } = useSWR(
open ? "clash-verge-rev-internal://network-interfaces" : null,
getNetworkInterfacesInfo,
{ fallbackData: [] }
);
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Network Interface")}
<Box>
<Button
variant="contained"
size="small"
onClick={() => {
setIsV4((prev) => !prev);
}}
>
{isV4 ? "Ipv6" : "Ipv4"}
</Button>
</Box>
</Box>
}
contentSx={{ width: 450 }}
disableOk
cancelBtn={t("Close")}
onCancel={() => setOpen(false)}
>
{networkInterfaces.map((item) => (
<Box key={item.name}>
<h4>{item.name}</h4>
<Box>
{isV4 && (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="flex justify-between items-center pr-12">
<DialogTitle>{t("Network Interface")}</DialogTitle>
<div className="flex items-center rounded-md border bg-muted p-0.5">
{/* --- НАЧАЛО ИЗМЕНЕНИЙ --- */}
{/* Меняем `secondary` на `default` для активной кнопки */}
<Button variant={isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(true)}>IPv4</Button>
<Button variant={!isV4 ? "default" : "ghost"} size="sm" className="px-3 text-xs" onClick={() => setIsV4(false)}>IPv6</Button>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ --- */}
</div>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
{networkInterfaces?.map((item, index) => (
<div key={item.name} className="py-2">
<h4 className="font-semibold text-base mb-1">{item.name}</h4>
<div>
{isV4 ? (
<>
{item.addr.map(
(address) =>
address.V4 && (
<AddressDisplay
key={address.V4.ip}
label={t("Ip Address")}
content={address.V4.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 label={t("Mac Address")} content={item.mac_addr ?? ""} />
</>
) : (
<>
{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 ?? ""} />
</>
)}
{!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 ?? ""}
/>
</>
)}
</Box>
</Box>
</div>
{index < networkInterfaces.length - 1 && <Separator className="mt-2"/>}
</div>
))}
</BaseDialog>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
});
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 {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField,
} from "@mui/material";
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
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 {
// Компонент теперь сам управляет своим состоянием,
// но вызывает onConfirm при подтверждении
onConfirm: (passwd: string) => Promise<void>;
// onCancel?: () => void; // Можно добавить, если нужна кнопка отмены
}
export const PasswordInput = (props: Props) => {
const { onConfirm } = props;
const { t } = useTranslation();
const [passwd, setPasswd] = useState("");
useEffect(() => {
if (!open) return;
}, [open]);
const handleSubmit = async (event?: React.FormEvent) => {
// Предотвращаем стандартную отправку формы
event?.preventDefault();
await onConfirm(passwd);
};
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>
<TextField
sx={{ mt: 1 }}
autoFocus
label={t("Password")}
fullWidth
size="small"
<form onSubmit={handleSubmit}>
<div className="py-4">
<Label htmlFor="password-input">{t("Password")}</Label>
<Input
id="password-input"
type="password"
autoFocus
value={passwd}
onKeyDown={(e) => e.key === "Enter" && onConfirm(passwd)}
onChange={(e) => setPasswd(e.target.value)}
></TextField>
</DialogContent>
className="mt-2"
/>
</div>
{/* Скрытая кнопка для того, чтобы Enter в поле ввода вызывал onSubmit */}
<button type="submit" className="hidden" />
</form>
<DialogActions>
<Button
onClick={async () => await onConfirm(passwd)}
variant="contained"
>
<AlertDialogFooter>
{/* У этого диалога нет кнопки отмены */}
<AlertDialogAction asChild>
<Button type="button" onClick={handleSubmit}>
{t("Confirm")}
</Button>
</DialogActions>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -1,21 +1,32 @@
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 { 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 {
label: ReactNode;
extra?: ReactNode;
children?: ReactNode;
secondary?: ReactNode;
extra?: ReactNode; // Для иконок-подсказок рядом с лейблом
children?: ReactNode; // Для элементов управления (Switch, Select и т.д.)
secondary?: ReactNode; // Для текста-описания под лейблом
onClick?: () => void | Promise<any>;
}
@@ -23,16 +34,11 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
const { label, extra, children, secondary, onClick } = props;
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 handleClick = () => {
if (onClick) {
// Если onClick - асинхронная функция, показываем спиннер
if (isAsyncFunction(onClick)) {
setIsLoading(true);
onClick()!.finally(() => setIsLoading(false));
@@ -42,44 +48,34 @@ export const SettingItem: React.FC<ItemProps> = (props) => {
}
};
return clickable ? (
<ListItem disablePadding>
<ListItemButton onClick={handleClick} disabled={isLoading}>
<ListItemText primary={primary} secondary={secondary} />
{isLoading ? (
<CircularProgress color="inherit" size={20} />
) : (
<ChevronRightRounded />
return (
<div
onClick={clickable ? handleClick : undefined}
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"
)}
</ListItemButton>
</ListItem>
>
{/* Левая часть: заголовок и описание */}
<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 ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : clickable ? (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
) : (
<ListItem sx={{ pt: "5px", pb: "5px" }}>
<ListItemText primary={primary} secondary={secondary} />
{children}
</ListItem>
children
)}
</div>
</div>
);
};
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 {
value?: string;
onChange?: (value: string) => void;
onChange?: (value: StackMode) => void;
}
export const StackModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
// Массив с опциями для удобного рендеринга
const modes: StackMode[] = ["system", "gvisor", "mixed"];
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
// Используем наш стандартный контейнер для создания группы кнопок
<div className="flex items-center rounded-md border bg-muted p-0.5">
{modes.map((mode) => (
<Button
variant={value?.toLowerCase() === "system" ? "contained" : "outlined"}
onClick={() => onChange?.("system")}
sx={{ textTransform: "capitalize" }}
key={mode}
// Активная кнопка получает основной цвет темы
variant={value?.toLowerCase() === mode ? "default" : "ghost"}
onClick={() => onChange?.(mode)}
size="sm"
className="capitalize px-3 text-xs"
>
System
{/* Используем t() для возможной локализации в будущем */}
{t(mode)}
</Button>
<Button
variant={value?.toLowerCase() === "gvisor" ? "contained" : "outlined"}
onClick={() => onChange?.("gvisor")}
sx={{ textTransform: "capitalize" }}
>
gVisor
</Button>
<Button
variant={value?.toLowerCase() === "mixed" ? "contained" : "outlined"}
onClick={() => onChange?.("mixed")}
sx={{ textTransform: "capitalize" }}
>
Mixed
</Button>
</ButtonGroup>
))}
</div>
);
};

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 { TooltipIcon } from "@/components/base/base-tooltip-icon";
import { EditorViewer } from "@/components/profile/editor-viewer";
import { useVerge } from "@/hooks/use-verge";
import { useAppData } from "@/providers/app-data-provider";
@@ -14,66 +20,71 @@ import {
} from "@/services/cmds";
import { showNotice } from "@/services/noticeService";
import getSystem from "@/utils/get-system";
import { EditRounded } from "@mui/icons-material";
import {
Autocomplete,
Button,
InputAdornment,
List,
ListItem,
ListItemText,
styled,
TextField,
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;
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 { Textarea } from "@/components/ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Check, ChevronsUpDown, Edit, Loader2 } from "lucide-react";
import { cn } from "@root/lib/utils";
import { TooltipIcon } from "@/components/base/base-tooltip-icon";
// --- Вся ваша оригинальная логика, константы и хелперы ---
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 rDomainSimple = String.raw`(?:[a-z0-9\-\*]+\.|\*)*` + String.raw`(?:\w{2,64}\*?|\*)`;
const ipv6_part = "(?:[a-fA-F0-9:])+";
const rLocal = `localhost|<local>|localdomain`;
const getValidReg = (isWindows: boolean) => {
// 127.0.0.1 (full ipv4)
const rIPv4Unix = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}(?:\/\d{1,2})?`;
const rIPv4Windows = String.raw`(?:${ipv4_part}\.){3}${ipv4_part}`;
const rIPv6Unix = String.raw`(?:${ipv6_part}:+)+${ipv6_part}(?:\/\d{1,3})?`;
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 rValid = String.raw`^(${rValidPart})(?:${separator}\s?(${rValidPart}))*${separator}?$`;
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) => {
const { t } = useTranslation();
const isWindows = getSystem() === "windows";
@@ -91,57 +102,25 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
type AutoProxy = Awaited<ReturnType<typeof getAutotemProxy>>;
const [autoproxy, setAutoproxy] = useState<AutoProxy>();
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 ?? {};
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 ?? {};
const [value, setValue] = useState({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
guard: enable_proxy_guard, bypass: system_proxy_bypass, 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",
});
const defaultBypass = () => {
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>";
}
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 (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>";
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>";
};
const { data: clashConfig, mutate: mutateClash } = useSWR(
"getClashConfig",
getClashConfig,
{
revalidateOnFocus: false,
revalidateIfStale: true,
dedupingInterval: 1000,
errorRetryInterval: 5000,
},
);
const [prevMixedPort, setPrevMixedPort] = useState(
clashConfig?.["mixed-port"],
);
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig, { revalidateOnFocus: false, revalidateIfStale: true, dedupingInterval: 1000, errorRetryInterval: 5000 });
const [prevMixedPort, setPrevMixedPort] = useState(clashConfig?.["mixed-port"]);
useEffect(() => {
if (
clashConfig?.["mixed-port"] &&
clashConfig?.["mixed-port"] !== prevMixedPort
) {
if (clashConfig?.["mixed-port"] && clashConfig?.["mixed-port"] !== prevMixedPort) {
setPrevMixedPort(clashConfig?.["mixed-port"]);
resetSystemProxy();
}
@@ -151,36 +130,20 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
try {
const currentSysProxy = await getSystemProxy();
const currentAutoProxy = await getAutotemProxy();
if (value.pac ? currentAutoProxy?.enable : currentSysProxy?.enable) {
// 临时关闭系统代理
await patchVergeConfig({ enable_system_proxy: false });
// 减少等待时间
await new Promise((resolve) => setTimeout(resolve, 200));
// 重新开启系统代理
await patchVergeConfig({ enable_system_proxy: true });
// 更新UI状态
await Promise.all([
mutate("getSystemProxy"),
mutate("getAutotemProxy"),
]);
}
} catch (err: any) {
showNotice("error", err.toString());
await Promise.all([ mutate("getSystemProxy"), mutate("getAutotemProxy") ]);
}
} catch (err: any) { showNotice("error", err.toString()); }
};
const { systemProxyAddress } = useAppData();
// 为当前状态计算系统代理地址
const getSystemProxyAddress = useMemo(() => {
if (!clashConfig) return "-";
const isPacMode = value.pac ?? false;
if (isPacMode) {
const host = value.proxy_host || "127.0.0.1";
const port = verge?.verge_mixed_port || clashConfig["mixed-port"] || 7897;
@@ -188,448 +151,160 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
} else {
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 host = value.proxy_host || "127.0.0.1";
// 根据环境判断PAC端口
const port = import.meta.env.DEV ? 11233 : 33331;
return `http://${host}:${port}/commands/pac`;
}, [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, () => ({
open: () => {
setOpen(true);
setValue({
guard: enable_proxy_guard,
bypass: system_proxy_bypass,
duration: proxy_guard_duration ?? 10,
use_default: use_default_bypass ?? true,
pac: proxy_auto_config,
pac_content: pac_file_content ?? DEFAULT_PAC,
guard: enable_proxy_guard, bypass: system_proxy_bypass, 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",
});
getSystemProxy().then((p) => setSysproxy(p));
getAutotemProxy().then((p) => setAutoproxy(p));
getSystemProxy().then(setSysproxy);
getAutotemProxy().then(setAutoproxy);
fetchNetworkInterfaces();
},
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 () => {
if (value.duration < 1) {
showNotice(
"error",
t("Proxy Daemon Duration Cannot be Less than 1 Second"),
);
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;
}
if (value.duration < 1) { showNotice("error", t("Proxy Daemon Duration Cannot be Less than 1 Second")); return; }
if (value.bypass && !validReg.test(value.bypass)) { showNotice("error", t("Invalid Bypass Format")); return; }
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);
setOpen(false);
setSaving(false);
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;
}
let proxyHost = value.proxy_host;
if (ipv6Regex.test(proxyHost) && !proxyHost.startsWith("[") && !proxyHost.endsWith("]")) { proxyHost = `[${proxyHost}]`; }
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;
if (pacContent) {
pacContent = pacContent.replace(/%proxy_host%/g, value.proxy_host);
// 将 mixed-port 转换为字符串
const mixedPortStr = (clashConfig?.["mixed-port"] || "").toString();
pacContent = pacContent.replace(/%mixed-port%/g, mixedPortStr);
}
if (pacContent !== pac_file_content) {
patch.pac_file_content = pacContent;
}
// 处理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);
}
setTimeout(() => {
if (enabled) resetSystemProxy();
}, 50);
} catch (err: any) {
console.error("配置保存失败:", err);
mutateVerge();
showNotice("error", err.toString());
// setOpen(true);
} finally {
setSaving(false);
setOpen(false);
}
});
});
return (
<BaseDialog
open={open}
title={t("System Proxy Setting")}
contentSx={{ width: 450, maxHeight: 565 }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
loading={saving}
disableOk={saving}
>
<List>
<BaseFieldset label={t("Current System Proxy")} padding="15px 10px">
<FlexBox>
<Typography className="label">{t("Enable status")}</Typography>
<Typography className="value">
{value.pac
? autoproxy?.enable
? t("Enabled")
: t("Disabled")
: sysproxy?.enable
? t("Enabled")
: t("Disabled")}
</Typography>
</FlexBox>
{!value.pac && (
<>
<FlexBox>
<Typography className="label">{t("Server Addr")}</Typography>
<Typography className="value">
{getSystemProxyAddress}
</Typography>
</FlexBox>
</>
)}
{value.pac && (
<FlexBox>
<Typography className="label">{t("PAC URL")}</Typography>
<Typography className="value">
{getCurrentPacUrl || "-"}
</Typography>
</FlexBox>
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader><DialogTitle>{t("System Proxy Setting")}</DialogTitle></DialogHeader>
<div className="max-h-[70vh] overflow-y-auto space-y-4 py-4 px-1">
<BaseFieldset label={t("Current System Proxy")}>
<div className="text-sm space-y-2">
<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>
{!value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("Server Addr")}</span><span className="font-mono">{getSystemProxyAddress}</span></div>}
{value.pac && <div className="flex justify-between"><span className="text-muted-foreground">{t("PAC URL")}</span><span className="font-mono">{getCurrentPacUrl || "-"}</span></div>}
</div>
</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) => {
setValue((v) => ({
...v,
proxy_host: newValue || "127.0.0.1",
}));
}}
onInputChange={(_, newInputValue) => {
setValue((v) => ({
...v,
proxy_host: newInputValue || "127.0.0.1",
}));
}}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Use PAC Mode")} />
<Switch
edge="end"
disabled={!enabled}
checked={value.pac}
onChange={(_, e) => setValue((v) => ({ ...v, pac: e }))}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText
primary={t("Proxy Guard")}
sx={{ maxWidth: "fit-content" }}
/>
<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>
<SettingRow label={t("Proxy Host")}>
<Combobox options={hostOptions} value={value.proxy_host} onValueChange={(val) => setValue(v => ({...v, proxy_host: val}))} placeholder="127.0.0.1" />
</SettingRow>
<SettingRow label={t("Use PAC Mode")}>
<Switch disabled={!enabled} checked={value.pac} onCheckedChange={(e) => setValue((v) => ({ ...v, pac: e }))} />
</SettingRow>
<SettingRow label={<>{t("Proxy Guard")} <TooltipIcon tooltip={t("Proxy Guard Info")} /></>}>
<Switch disabled={!enabled} checked={value.guard} onCheckedChange={(e) => setValue((v) => ({ ...v, guard: e }))} />
</SettingRow>
<SettingRow label={t("Guard Duration")}>
<div className="flex items-center gap-2">
<Input disabled={!enabled} type="number" className="w-24 h-8" value={value.duration} onChange={(e) => setValue((v) => ({ ...v, duration: +e.target.value.replace(/\D/, "") }))}/>
<span className="text-sm text-muted-foreground">s</span>
</div>
</SettingRow>
{!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>
<SettingRow label={t("Always use Default Bypass")}>
<Switch disabled={!enabled} checked={value.use_default} onCheckedChange={(e) => setValue((v) => ({...v, use_default: e, bypass: !e && !v.bypass ? defaultBypass() : v.bypass}))}/>
</SettingRow>
)}
{!value.pac && !value.use_default && (
<>
<ListItemText primary={t("Proxy Bypass")} />
<TextField
error={value.bypass ? !validReg.test(value.bypass) : false}
<div className="space-y-2">
<Label>{t("Proxy Bypass")}</Label>
<Textarea
id="proxy-bypass"
disabled={!enabled}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={value.bypass}
onChange={(e) => {
setValue((v) => ({ ...v, bypass: e.target.value }));
}}
/>
</>
onChange={(e) => setValue((v) => ({ ...v, bypass: e.target.value }))}
// Вместо пропса `error` используем условные классы
className={cn(
(value.bypass && !validReg.test(value.bypass)) && "border-destructive focus-visible:ring-destructive"
)}
{!value.pac && value.use_default && (
<>
<ListItemText primary={t("Bypass")} />
<FlexBox>
<TextField
disabled={true}
size="small"
multiline
rows={4}
sx={{ width: "100%" }}
value={defaultBypass()}
/>
</FlexBox>
</>
</div>
)}
{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)}
/>
<SettingRow label={t("PAC Script Content")}>
<Button variant="outline" size="sm" onClick={() => setEditorOpen(true)}><Edit className="mr-2 h-4 w-4"/>{t("Edit")} PAC</Button>
</SettingRow>
)}
</ListItem>
</div>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave} disabled={saving}>{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</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)} />}
</>
)}
</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 { Button, ButtonGroup } from "@mui/material";
import { Button } from "@/components/ui/button";
type ThemeValue = IVergeConfig["theme_mode"];
// Определяем возможные значения темы для TypeScript
type ThemeValue = "light" | "dark" | "system";
interface Props {
value?: ThemeValue;
@@ -12,20 +13,25 @@ export const ThemeModeSwitch = (props: Props) => {
const { value, onChange } = props;
const { t } = useTranslation();
const modes = ["light", "dark", "system"] as const;
const modes: ThemeValue[] = ["light", "dark", "system"];
return (
<ButtonGroup size="small" sx={{ my: "4px" }}>
// Создаем ту же самую группу кнопок, что и раньше
<div className="flex items-center rounded-md border bg-muted p-0.5">
{modes.map((mode) => (
<Button
key={mode}
variant={mode === value ? "contained" : "outlined"}
variant={mode === value ? "default" : "ghost"}
onClick={() => onChange?.(mode)}
sx={{ textTransform: "capitalize" }}
size="sm"
className="capitalize px-3 text-xs"
>
{/* Ключевое исправление: мы используем ключи `theme.light`, `theme.dark` и т.д.
Это стандартный подход в i18next для корректной локализации.
*/}
{t(`theme.${mode}`)}
</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 { useTranslation } from "react-i18next";
import {
Button,
List,
ListItem,
ListItemText,
styled,
TextField,
useTheme,
} from "@mui/material";
import { useTheme } from "@mui/material/styles"; // Оставляем для получения дефолтных цветов темы
// Новые импорты
import { useVerge } from "@/hooks/use-verge";
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 { EditRounded } from "@mui/icons-material";
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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const { verge, patchVerge } = useVerge();
@@ -34,12 +70,6 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
close: () => setOpen(false),
}));
const textProps = {
size: "small",
autoComplete: "off",
sx: { width: 135 },
} as const;
const handleChange = (field: keyof typeof theme) => (e: any) => {
setTheme((t) => ({ ...t, [field]: e.target.value }));
};
@@ -48,82 +78,74 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
try {
await patchVerge({ theme_setting: theme });
setOpen(false);
showNotice("success", t("Saved Successfully, please restart the app to take effect"));
} catch (err: any) {
showNotice("error", err.toString());
}
});
// default theme
const { palette } = useTheme();
const dt = palette.mode === "light" ? defaultTheme : defaultDarkTheme;
const muiTheme = useTheme();
const dt = muiTheme.palette.mode === "light" ? defaultTheme : defaultDarkTheme;
type ThemeKey = keyof typeof theme & keyof typeof defaultTheme;
const renderItem = (label: string, key: ThemeKey) => {
return (
<Item>
<ListItemText primary={label} />
<Round sx={{ background: theme[key] || dt[key] }} />
<TextField
{...textProps}
<ColorSettingRow
label={label}
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Добавляем `?? ''` чтобы value всегда был строкой
value={theme[key] ?? ""}
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
placeholder={dt[key]}
onChange={handleChange(key)}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
);
};
return (
<BaseDialog
open={open}
title={t("Theme Setting")}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
contentSx={{ width: 400, maxHeight: 505, overflow: "auto", pb: 0 }}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onSave}
>
<List sx={{ pt: 0 }}>
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("Theme Setting")}</DialogTitle>
</DialogHeader>
<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")}
<Item>
<ListItemText primary={t("Font Family")} />
<TextField
{...textProps}
<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")}
onKeyDown={(e) => e.key === "Enter" && onSave()}
/>
</Item>
<Item>
<ListItemText primary={t("CSS Injection")} />
<Button
startIcon={<EditRounded />}
variant="outlined"
onClick={() => {
setEditorOpen(true);
}}
>
{t("Edit")} CSS
</div>
<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>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onSave}>{t("Save")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{editorOpen && (
<EditorViewer
open={true}
@@ -131,28 +153,11 @@ export const ThemeViewer = forwardRef<DialogRef>((props, ref) => {
initialData={Promise.resolve(theme.css_injection ?? "")}
language="css"
onSave={(_prev, curr) => {
theme.css_injection = curr;
handleChange("css_injection");
}}
onClose={() => {
setEditorOpen(false);
setTheme(v => ({ ...v, css_injection: curr }));
}}
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 { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import {
List,
ListItem,
ListItemText,
Box,
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 { useLockFn, useRequest } from "ahooks";
import { mutate } from "swr";
import { useClash, useClashInfo } from "@/hooks/use-clash";
import { useVerge } from "@/hooks/use-verge";
import { enhanceProfiles, restartCore } from "@/services/cmds";
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();
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) => {
const { t } = useTranslation();
const { clash, mutateClash, patchClash } = useClash();
const [open, setOpen] = useState(false);
const [values, setValues] = useState({
stack: "mixed",
stack: "gvisor" as StackMode,
device: OS === "macos" ? "utun1024" : "Mihomo",
autoRoute: true,
autoDetectInterface: true,
@@ -39,7 +58,10 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
open: () => {
setOpen(true);
setValues({
stack: clash?.tun.stack ?? "gvisor",
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Добавляем утверждение типа, чтобы TypeScript был уверен в значении
stack: (clash?.tun.stack as StackMode) ?? "gvisor",
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
device: clash?.tun.device ?? (OS === "macos" ? "utun1024" : "Mihomo"),
autoRoute: clash?.tun["auto-route"] ?? true,
autoDetectInterface: clash?.tun["auto-detect-interface"] ?? true,
@@ -51,16 +73,23 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
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 () => {
try {
let tun = {
const tun = {
stack: values.stack,
device:
values.device === ""
? OS === "macos"
? "utun1024"
: "Mihomo"
: values.device,
device: values.device === "" ? (OS === "macos" ? "utun1024" : "Mihomo") : values.device,
"auto-route": values.autoRoute,
"auto-detect-interface": values.autoDetectInterface,
"dns-hijack": values.dnsHijack[0] === "" ? [] : values.dnsHijack,
@@ -68,13 +97,7 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
mtu: values.mtu ?? 1500,
};
await patchClash({ tun });
await mutateClash(
(old) => ({
...(old! || {}),
tun,
}),
false,
);
await mutateClash((old) => ({ ...(old! || {}), tun }), false);
try {
await enhanceProfiles();
showNotice("success", t("Settings Applied"));
@@ -88,152 +111,50 @@ export const TunViewer = forwardRef<DialogRef>((props, ref) => {
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between" gap={1}>
<Typography variant="h6">{t("Tun Mode")}</Typography>
<Button
variant="outlined"
size="small"
onClick={async () => {
let tun = {
stack: "gvisor",
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,
);
}}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex justify-between items-center pr-12">
<DialogTitle>{t("Tun Mode")}</DialogTitle>
<Button variant="outline" size="sm" onClick={resetToDefaults}>
<RotateCcw className="mr-2 h-4 w-4" />
{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")} />
</div>
</DialogHeader>
<div className="max-h-[70vh] overflow-y-auto space-y-1 px-1">
<SettingRow label={<LabelWithIcon icon={Layers} text={t("Stack")} />}>
<StackModeSwitch
value={values.stack}
onChange={(value) => {
setValues((v) => ({
...v,
stack: value,
}));
}}
onChange={(value) => setValues((v) => ({ ...v, stack: value }))}
/>
</ListItem>
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Laptop} text={t("Device")} />}>
<Input className="h-8 w-40" value={values.device} placeholder="Mihomo" onChange={(e) => setValues((v) => ({ ...v, device: e.target.value }))} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={Route} text={t("Auto Route")} />}>
<Switch checked={values.autoRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, autoRoute: c }))} />
</SettingRow>
<SettingRow label={<LabelWithIcon icon={RouteOff} text={t("Strict Route")} />}>
<Switch checked={values.strictRoute} onCheckedChange={(c) => setValues((v) => ({ ...v, strictRoute: c }))} />
</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" }}>
<ListItemText primary={t("Device")} />
<TextField
autoComplete="new-password"
size="small"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
sx={{ width: 250 }}
value={values.device}
placeholder="Mihomo"
onChange={(e) =>
setValues((v) => ({ ...v, device: e.target.value }))
}
/>
</ListItem>
<ListItem sx={{ padding: "5px 2px" }}>
<ListItemText primary={t("Auto Route")} />
<Switch
edge="end"
checked={values.autoRoute}
onChange={(_, c) => setValues((v) => ({ ...v, autoRoute: c }))}
/>
</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>
<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,31 +1,31 @@
import useSWR from "swr";
import {
forwardRef,
useImperativeHandle,
useState,
useMemo,
useEffect,
} from "react";
import { forwardRef, useImperativeHandle, useState, useMemo, useEffect } from "react";
import { useLockFn } from "ahooks";
import { Box, LinearProgress, Button } from "@mui/material";
import { useTranslation } from "react-i18next";
import { relaunch } from "@tauri-apps/plugin-process";
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 { portableFlag } from "@/pages/_layout";
import { open as openUrl } from "@tauri-apps/plugin-shell";
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 { 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) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [currentProgressListener, setCurrentProgressListener] =
useState<UnlistenFn | null>(null);
const [currentProgressListener, setCurrentProgressListener] = useState<UnlistenFn | null>(null);
const updateState = useUpdateState();
const setUpdateState = useSetUpdateState();
@@ -34,11 +34,10 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
focusThrottleInterval: 36e5,
});
const [downloaded, setDownloaded] = useState(0);
const [buffer, setBuffer] = useState(0);
const [total, setTotal] = useState(0);
useImperativeHandle(ref, () => ({
@@ -47,44 +46,29 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
}));
const markdownContent = useMemo(() => {
if (!updateInfo?.body) {
return "New Version is available";
}
return updateInfo?.body;
}, [updateInfo]);
if (!updateInfo?.body) return t("New Version is available");
return updateInfo.body;
}, [updateInfo, t]);
const breakChangeFlag = useMemo(() => {
if (!updateInfo?.body) {
return false;
}
return updateInfo?.body.toLowerCase().includes("break change");
return updateInfo?.body?.toLowerCase().includes("break change") ?? false;
}, [updateInfo]);
const onUpdate = useLockFn(async () => {
if (portableFlag) {
showNotice("error", t("Portable Updater Error"));
return;
}
if (portableFlag) { showNotice("error", t("Portable Updater Error")); return; }
if (!updateInfo?.body) return;
if (breakChangeFlag) {
showNotice("error", t("Break Change Update Error"));
return;
}
if (breakChangeFlag) { showNotice("error", t("Break Change Update Error")); return; }
if (updateState) return;
setUpdateState(true);
setDownloaded(0); // Сбрасываем прогресс перед новой загрузкой
setTotal(0);
if (currentProgressListener) {
currentProgressListener();
}
if (currentProgressListener) currentProgressListener();
const progressListener = await addListener(
"tauri://update-download-progress",
(e: Event<any>) => {
const progressListener = await addListener("tauri://update-download-progress", (e: Event<any>) => {
setTotal(e.payload.contentLength);
setBuffer(e.payload.chunkLength);
setDownloaded((a) => {
return a + e.payload.chunkLength;
});
setDownloaded((prev) => prev + e.payload.chunkLength);
},
);
setCurrentProgressListener(() => progressListener);
@@ -96,74 +80,66 @@ export const UpdateViewer = forwardRef<DialogRef>((props, ref) => {
showNotice("error", err?.message || err.toString());
} finally {
setUpdateState(false);
if (progressListener) {
progressListener();
}
progressListener?.();
setCurrentProgressListener(null);
}
});
useEffect(() => {
return () => {
if (currentProgressListener) {
console.log("UpdateViewer unmounting, cleaning up progress listener.");
currentProgressListener();
}
};
return () => { currentProgressListener?.(); };
}, [currentProgressListener]);
const downloadProgress = total > 0 ? (downloaded / total) * 100 : 0;
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{`New Version v${updateInfo?.version}`}
<Box>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<div className="flex justify-between items-center">
<DialogTitle>{t("New Version")} v{updateInfo?.version}</DialogTitle>
<Button
variant="contained"
size="small"
onClick={() => {
openUrl(
`https://github.com/clash-verge-rev/clash-verge-rev/releases/tag/v${updateInfo?.version}`,
);
}}
variant="outline"
size="sm"
onClick={() => 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")}
</Button>
</Box>
</Box>
}
contentSx={{ minWidth: 360, maxWidth: 400, height: "50vh" }}
okBtn={t("Update")}
cancelBtn={t("Cancel")}
onClose={() => setOpen(false)}
onCancel={() => setOpen(false)}
onOk={onUpdate}
>
<Box sx={{ height: "calc(100% - 10px)", overflow: "auto" }}>
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto my-4 pr-6 -mr-6">
{breakChangeFlag && (
<Alert variant="destructive" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("Warning")}</AlertTitle>
<AlertDescription>{t("Break Change Warning")}</AlertDescription>
</Alert>
)}
{/* Оборачиваем ReactMarkdown для красивой стилизации */}
<article className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
components={{
a: ({ node, ...props }) => {
const { children } = props;
return (
<a {...props} target="_blank">
{children}
</a>
);
},
}}
components={{ a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
>
{markdownContent}
</ReactMarkdown>
</Box>
</article>
</div>
{updateState && (
<LinearProgress
variant="buffer"
value={(downloaded / total) * 100}
valueBuffer={buffer}
sx={{ marginTop: "5px" }}
/>
<div className="w-full space-y-1">
<Progress value={downloadProgress} />
<p className="text-xs text-muted-foreground text-right">{Math.round(downloadProgress)}%</p>
</div>
)}
</BaseDialog>
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={onUpdate} disabled={updateState || breakChangeFlag}>
{t("Update")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

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

View File

@@ -1,17 +1,28 @@
import { forwardRef, useImperativeHandle, useState } from "react";
import { useLockFn } from "ahooks";
import { forwardRef, useImperativeHandle, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Button, Box, Typography } from "@mui/material";
import { useVerge } from "@/hooks/use-verge";
import { useLockFn } from "ahooks";
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 { WebUIItem } from "./web-ui-item";
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) => {
const { t } = useTranslation();
const { clashInfo } = useClashInfo();
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",
];
// Вся ваша логика остается без изменений
const handleAdd = useLockFn(async (value: string) => {
const newList = [...webUIList, value];
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(":")) {
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(
"%secret",
encodeURIComponent(clashInfo.secret || ""),
);
url = url.replaceAll("%secret", encodeURIComponent(clashInfo.secret || ""));
}
await openWebUrl(url);
} catch (e: any) {
showNotice("error", e.message || e.toString());
@@ -78,44 +82,31 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
});
return (
<BaseDialog
open={open}
title={
<Box display="flex" justifyContent="space-between">
{t("Web UI")}
<Button
variant="contained"
size="small"
disabled={editing}
onClick={() => setEditing(true)}
>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader className="pr-7">
<div className="flex justify-between items-center">
<DialogTitle>{t("Web UI")}</DialogTitle>
<Button size="sm" disabled={editing} onClick={() => setEditing(true)}>
<Plus className="mr-2 h-4 w-4" />
{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 && (
</div>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto -mx-6 px-6">
{!editing && webUIList.length === 0 ? (
<div className="h-40"> {/* Задаем высоту для центрирования */}
<BaseEmpty
extra={
<Typography mt={2} sx={{ fontSize: "12px" }}>
<p className="mt-2 text-xs text-center">
{t("Replace host, port, secret with %host, %port, %secret")}
</Typography>
</p>
}
/>
)}
{webUIList.map((item, index) => (
</div>
) : (
webUIList.map((item, index) => (
<WebUIItem
key={index}
value={item}
@@ -123,18 +114,27 @@ export const WebUIViewer = forwardRef<DialogRef>((props, ref) => {
onDelete={() => handleDelete(index)}
onOpenUrl={handleOpenUrl}
/>
))}
))
)}
{editing && (
<WebUIItem
value=""
onlyEdit
onChange={(v) => {
setEditing(false);
handleAdd(v || "");
if (v) handleAdd(v);
}}
onCancel={() => setEditing(false)}
/>
)}
</BaseDialog>
</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 { 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 { ClashPortViewer } from "./mods/clash-port-viewer";
import { ControllerViewer } from "./mods/controller-viewer";
import { DnsViewer } from "./mods/dns-viewer";
import { GuardState } from "./mods/guard-state";
import { NetworkInterfaceViewer } from "./mods/network-interface-viewer";
import { SettingItem, SettingList } from "./mods/setting-comp";
import { WebUIViewer } from "./mods/web-ui-viewer";
const isWIN = getSystem() === "windows";
@@ -28,6 +46,20 @@ interface Props {
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 { t } = useTranslation();
@@ -39,18 +71,14 @@ const SettingClash = ({ onError }: Props) => {
"allow-lan": allowLan,
"log-level": logLevel,
"unified-delay": unifiedDelay,
dns,
} = clash ?? {};
const { enable_random_port = false, verge_mixed_port } = verge ?? {};
const { verge_mixed_port } = verge ?? {};
// 独立跟踪DNS设置开关状态
const [dnsSettingsEnabled, setDnsSettingsEnabled] = useState(() => {
return verge?.enable_dns_settings ?? false;
});
const { addListener } = useListen();
const webRef = useRef<DialogRef>(null);
const portRef = useRef<DialogRef>(null);
const ctrlRef = useRef<DialogRef>(null);
@@ -58,23 +86,22 @@ const SettingClash = ({ onError }: Props) => {
const networkRef = 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>) => {
mutateClash((old) => ({ ...(old! || {}), ...patch }), false);
};
const onChangeVerge = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
const onUpdateGeo = async () => {
const onUpdateGeo = useLockFn(async () => {
try {
await updateGeoData();
showNotice("success", t("GeoData Updated"));
} 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) => {
try {
setDnsSettingsEnabled(enable);
@@ -94,7 +121,9 @@ const SettingClash = ({ onError }: Props) => {
});
return (
<SettingList title={t("Clash Setting")}>
<div>
<h3 className="text-lg font-medium mb-4">{t("Clash Setting")}</h3>
<div className="space-y-1">
<WebUIViewer ref={webRef} />
<ClashPortViewer ref={portRef} />
<ControllerViewer ref={ctrlRef} />
@@ -102,162 +131,60 @@ const SettingClash = ({ onError }: Props) => {
<NetworkInterfaceViewer ref={networkRef} />
<DnsViewer ref={dnsRef} />
<SettingItem
label={t("Allow Lan")}
extra={
<TooltipIcon
title={t("Network Interface")}
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" />
<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()} />}>
<GuardState value={allowLan ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "allow-lan": e })} onGuard={(e) => patchClash({ "allow-lan": e })} onCatch={onError}>
<Switch />
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem
label={t("DNS Overwrite")}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => dnsRef.current?.open()}
/>
}
>
<Switch
edge="end"
checked={dnsSettingsEnabled}
onChange={(_, checked) => handleDnsToggle(checked)}
/>
</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()} />}>
<Switch checked={dnsSettingsEnabled} onCheckedChange={handleDnsToggle} />
</SettingRow>
<SettingItem label={t("IPv6")}>
<GuardState
value={ipv6 ?? false}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ ipv6: e })}
onGuard={(e) => patchClash({ ipv6: e })}
>
<Switch edge="end" />
<SettingRow label={<LabelWithIcon icon={Globe2} text={t("IPv6")} />}>
<GuardState value={ipv6 ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ ipv6: e })} onGuard={(e) => patchClash({ ipv6: e })} onCatch={onError}>
<Switch />
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem
label={t("Unified Delay")}
extra={
<TooltipIcon
title={t("Unified Delay Info")}
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" />
<SettingRow label={<LabelWithIcon icon={Timer} text={t("Unified Delay")} />} extra={<TooltipIcon tooltip={t("Unified Delay Info")} />}>
<GuardState value={unifiedDelay ?? false} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onChange={(e) => onChangeData({ "unified-delay": e })} onGuard={(e) => patchClash({ "unified-delay": e })} onCatch={onError}>
<Switch />
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem
label={t("Log Level")}
extra={
<TooltipIcon title={t("Log Level Info")} sx={{ opacity: "0.7" }} />
}
>
<GuardState
value={logLevel === "warn" ? "warning" : (logLevel ?? "info")}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ "log-level": e })}
onGuard={(e) => patchClash({ "log-level": e })}
>
<Select size="small" sx={{ width: 100, "> div": { py: "7.5px" } }}>
<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>
<SettingRow label={<LabelWithIcon icon={FileText} text={t("Log Level")} />} extra={<TooltipIcon tooltip={t("Log Level Info")} />}>
<GuardState value={logLevel ?? "info"} valueProps="value" onChangeProps="onValueChange" onFormat={onSelectFormat} onChange={(e) => onChangeData({ "log-level": e })} onGuard={(e) => patchClash({ "log-level": e })} onCatch={onError}>
<Select value={logLevel}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="info">Info</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="silent">Silent</SelectItem>
<SelectItem value="debug">Debug</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem label={t("Port Config")}>
<TextField
autoComplete="new-password"
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>
<SettingRow label={<LabelWithIcon icon={Plug} text={t("Port Config")} />}>
<Button variant="outline" className="w-28 h-8 font-mono" onClick={() => portRef.current?.open()}>{verge_mixed_port ?? 7897}</Button>
</SettingRow>
<SettingItem
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" }}
/>
</>
}
/>
<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>} />
<SettingItem onClick={() => webRef.current?.open()} label={t("Web UI")} />
<SettingRow onClick={() => webRef.current?.open()} label={<LabelWithIcon icon={LayoutDashboard} text={t("Yacd Web UI")} />} />
<SettingItem
label={t("Clash Core")}
extra={
<TooltipIcon
icon={SettingsRounded}
onClick={() => coreRef.current?.open()}
/>
}
>
<Typography sx={{ py: "7px", pr: 1 }}>{version}</Typography>
</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()} />}>
<p className="text-sm font-medium pr-2 font-mono">{version}</p>
</SettingRow>
{isWIN && (
<SettingItem
onClick={invoke_uwp_tool}
label={t("Open UWP tool")}
extra={
<TooltipIcon
title={t("Open UWP tool Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
)}
{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={onUpdateGeo} label={t("Update GeoData")} />
</SettingList>
<SettingRow onClick={onUpdateGeo} label={<LabelWithIcon icon={MapIcon} text={t("Update GeoData")} />} />
</div>
</div>
);
};

View File

@@ -1,74 +1,75 @@
import { mutate } from "swr";
import { useRef } from "react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SettingsRounded,
PlayArrowRounded,
PauseRounded,
WarningRounded,
BuildRounded,
DeleteForeverRounded,
} from "@mui/icons-material";
import { useLockFn } from "ahooks";
import { mutate } from "swr";
import { invoke } from "@tauri-apps/api/core";
import getSystem from "@/utils/get-system";
// Сервисы и хуки
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 { 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 { Settings, PlayCircle, PauseCircle, AlertTriangle, Wrench, Trash2, Funnel, Monitor, Power, BellOff, Repeat } from "lucide-react";
// Модальные окна
import { SysproxyViewer } from "./mods/sysproxy-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";
import { useServiceInstaller } from "@/hooks/useServiceInstaller";
const isWIN = getSystem() === "windows";
interface Props { onError?: (err: Error) => void; }
interface Props {
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 SettingSystem = ({ onError }: Props) => {
const { t } = useTranslation();
const { verge, mutateVerge, patchVerge } = useVerge();
const { verge, patchVerge, mutateVerge } = useVerge();
const { installServiceAndRestartCore } = useServiceInstaller();
// --- НАЧАЛО ИСПРАВЛЕНИЯ ---
// Используем синтаксис переименования: `actualState` становится `systemProxyActualState`
const {
actualState: systemProxyActualState,
indicator: systemProxyIndicator,
toggleSystemProxy,
} = useSystemProxyState();
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
const { isAdminMode, isServiceMode, mutateRunningMode } = useSystemState();
// +++ isTunAvailable 现在使用 SWR 的 isServiceMode
const isTunAvailable = isServiceMode || isAdminMode;
const sysproxyRef = useRef<DialogRef>(null);
const tunRef = useRef<DialogRef>(null);
const { enable_tun_mode, enable_auto_launch, enable_silent_start } =
verge ?? {};
const { enable_tun_mode, enable_auto_launch, enable_silent_start } = verge ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onSwitchFormat = (val: boolean) => val;
const onChangeData = (patch: Partial<IVergeConfig>) => {
mutateVerge({ ...verge, ...patch }, false);
};
// 抽象服务操作逻辑
const handleServiceOperation = useLockFn(
async ({
beforeMsg,
action,
actionMsg,
successMsg,
}: {
beforeMsg: string;
action: () => Promise<void>;
actionMsg: string;
successMsg: string;
}) => {
const handleServiceOperation = useLockFn(async ({ beforeMsg, action, actionMsg, successMsg }: { beforeMsg: string; action: () => Promise<void>; actionMsg: string; successMsg: string; }) => {
try {
showNotice("info", beforeMsg);
await stopCore();
@@ -88,12 +89,9 @@ const SettingSystem = ({ onError }: Props) => {
showNotice("error", e?.message || e?.toString());
}
}
},
);
});
// 卸载系统服务
const onUninstallService = () =>
handleServiceOperation({
const onUninstallService = () => handleServiceOperation({
beforeMsg: t("Stopping Core..."),
action: uninstallService,
actionMsg: t("Uninstalling Service..."),
@@ -101,167 +99,82 @@ const SettingSystem = ({ onError }: Props) => {
});
return (
<SettingList title={t("System Setting")}>
<div>
<h3 className="text-lg font-medium mb-4">{t("System Setting")}</h3>
<div className="space-y-1">
<SysproxyViewer ref={sysproxyRef} />
<TunViewer ref={tunRef} />
<SettingItem
label={t("Tun Mode")}
<SettingRow
label={<LabelWithIcon icon={Funnel} text={t("Tun Mode")} />}
extra={
<>
<TooltipIcon
title={t("Tun Mode Info")}
icon={SettingsRounded}
onClick={() => tunRef.current?.open()}
/>
{!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>
)}
</>
<div className="flex items-center gap-1">
<TooltipIcon tooltip={t("Tun Mode Info")} icon={<Settings className="h-4 w-4" />} onClick={() => tunRef.current?.open()} />
{!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>}
{!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>}
{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>
}
>
<GuardState
value={enable_tun_mode ?? false}
valueProps="checked"
onCatch={onError}
onChangeProps="onCheckedChange"
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>
</SettingItem>
<SettingItem
label={t("System Proxy")}
extra={
<>
<TooltipIcon
title={t("System Proxy Info")}
icon={SettingsRounded}
onClick={() => sysproxyRef.current?.open()}
/>
{systemProxyIndicator ? (
<PlayArrowRounded sx={{ color: "success.main", mr: 1 }} />
) : (
<PauseRounded sx={{ color: "error.main", mr: 1 }} />
)}
</>
}
>
<GuardState
value={systemProxyActualState}
valueProps="checked"
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 }); }}
onCatch={onError}
onFormat={onSwitchFormat}
onGuard={(e) => toggleSystemProxy(e)}
>
<Switch edge="end" checked={systemProxyActualState} />
<Switch disabled={!isTunAvailable} />
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem
label={t("Auto Launch")}
<SettingRow
label={<LabelWithIcon icon={Monitor} text={t("System Proxy")} />}
extra={
isAdminMode && (
<Tooltip
title={t("Administrator mode may not support auto launch")}
>
<WarningRounded sx={{ color: "warning.main", mr: 1 }} />
</Tooltip>
)
<div className="flex items-center gap-2">
<TooltipIcon tooltip={t("System Proxy Info")} icon={<Settings className="h-4 w-4" />} onClick={() => sysproxyRef.current?.open()} />
{systemProxyIndicator ? <PlayCircle className="h-5 w-5 text-green-500" /> : <PauseCircle className="h-5 w-5 text-red-500" />}
</div>
}
>
<GuardState value={systemProxyActualState} valueProps="checked" onChangeProps="onCheckedChange" onFormat={onSwitchFormat} onGuard={(e) => toggleSystemProxy(e)} onCatch={onError}>
<Switch />
</GuardState>
</SettingRow>
<SettingRow
label={<LabelWithIcon icon={Power} text={t("Auto Launch")} />}
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>}
>
<GuardState
value={enable_auto_launch ?? false}
valueProps="checked"
onCatch={onError}
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 {
// 先触发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);
}
}}
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 edge="end" />
<Switch />
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem
label={t("Silent Start")}
extra={
<TooltipIcon title={t("Silent Start Info")} sx={{ opacity: "0.7" }} />
}
>
<SettingRow label={<LabelWithIcon icon={BellOff} text={t("Silent Start")} />} extra={<TooltipIcon tooltip={t("Silent Start Info")} />}>
<GuardState
value={enable_silent_start ?? false}
valueProps="checked"
onCatch={onError}
onChangeProps="onCheckedChange"
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_silent_start: e })}
onGuard={(e) => patchVerge({ enable_silent_start: e })}
onCatch={onError}
>
<Switch edge="end" />
<Switch />
</GuardState>
</SettingItem>
</SettingList>
</SettingRow>
</div>
</div>
);
};

View File

@@ -1,6 +1,9 @@
import { useCallback, useRef } from "react";
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 {
exitApp,
openAppDir,
@@ -9,11 +12,31 @@ import {
openDevTools,
exportDiagnosticInfo,
} from "@/services/cmds";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
import { useVerge } from "@/hooks/use-verge";
import { version } from "@root/package.json";
import { showNotice } from "@/services/noticeService";
// Компоненты
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 { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
@@ -22,18 +45,43 @@ import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-viewer";
import { BackupViewer } from "./mods/backup-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 {
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 { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
@@ -61,8 +109,21 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
showNotice("success", t("Copy Success"), 1000);
}, []);
// Вспомогательная функция для создания лейбла с иконкой
const LabelWithIcon = ({ icon, text }: { icon: React.ElementType, text: string }) => {
const Icon = icon;
return (
<SettingList title={t("Verge Advanced Setting")}>
<span className="flex items-center gap-3">
<Icon className="h-4 w-4 text-muted-foreground" />
{text}
</span>
);
};
return (
<div>
<h3 className="text-lg font-medium mb-4">{t("Verge Advanced Setting")}</h3>
<div className="space-y-1">
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
@@ -72,73 +133,27 @@ const SettingVergeAdvanced = ({ onError }: Props) => {
<BackupViewer ref={backupRef} />
<LiteModeViewer ref={liteModeRef} />
<SettingItem
onClick={() => backupRef.current?.open()}
label={t("Backup Setting")}
extra={
<TooltipIcon
title={t("Backup Setting Info")}
sx={{ opacity: "0.7" }}
/>
}
/>
{/* --- НАЧАЛО ИЗМЕНЕНИЙ 2: Добавляем иконки к каждому пункту --- */}
<SettingRow onClick={() => backupRef.current?.open()} label={<LabelWithIcon icon={Archive} text={t("Backup Setting")} />} extra={<TooltipIcon tooltip={t("Backup Setting Info")} />} />
<SettingRow onClick={() => configRef.current?.open()} label={<LabelWithIcon icon={FileCode} text={t("Runtime Config")} />} />
<SettingRow onClick={openAppDir} label={<LabelWithIcon icon={Folder} text={t("Open Conf Dir")} />} extra={<TooltipIcon tooltip={t("Open Conf Dir Info")} />} />
<SettingRow onClick={openCoreDir} label={<LabelWithIcon icon={FolderCog} text={t("Open Core Dir")} />} />
<SettingRow onClick={openLogsDir} label={<LabelWithIcon icon={FolderClock} text={t("Open Logs Dir")} />} />
<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
onClick={() => configRef.current?.open()}
label={t("Runtime Config")}
/>
<SettingRow label={<LabelWithIcon icon={ClipboardList} text={t("Export Diagnostic Info")} />}>
<TooltipIcon tooltip={t("Copy")} icon={<Copy className="h-4 w-4"/>} onClick={onExportDiagnosticInfo} />
</SettingRow>
<SettingItem
onClick={openAppDir}
label={t("Open Conf Dir")}
extra={
<TooltipIcon
title={t("Open Conf Dir Info")}
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>
<SettingRow label={<LabelWithIcon icon={Info} text={t("Verge Version")} />}>
<p className="text-sm font-medium pr-2 font-mono">v{version}</p>
</SettingRow>
{/* --- КОНЕЦ ИЗМЕНЕНИЙ 2 --- */}
</div>
</div>
);
};

View File

@@ -1,26 +1,36 @@
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { open } from "@tauri-apps/plugin-dialog";
import { Button, MenuItem, Select, Input } from "@mui/material";
import { copyClashEnv } from "@/services/cmds";
import { useVerge } from "@/hooks/use-verge";
import { DialogRef } from "@/components/base";
import { SettingList, SettingItem } from "./mods/setting-comp";
import { ThemeModeSwitch } from "./mods/theme-mode-switch";
import { languages } from "@/services/i18n";
import { showNotice } from "@/services/noticeService";
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 { HotkeyViewer } from "./mods/hotkey-viewer";
import { MiscViewer } from "./mods/misc-viewer";
import { ThemeViewer } from "./mods/theme-viewer";
import { GuardState } from "./mods/guard-state";
import { LayoutViewer } from "./mods/layout-viewer";
import { UpdateViewer } from "./mods/update-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 {
onError?: (err: Error) => void;
@@ -30,31 +40,29 @@ const OS = getSystem();
const languageOptions = Object.entries(languages).map(([code, _]) => {
const labels: { [key: string]: string } = {
en: "English",
ru: "Русский",
zh: "中文",
fa: "فارسی",
tt: "Татар",
id: "Bahasa Indonesia",
ar: "العربية",
ko: "한국어",
tr: "Türkçe",
en: "English", ru: "Русский", 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 { t } = useTranslation();
const { verge, patchVerge, mutateVerge } = useVerge();
const {
theme_mode,
language,
tray_event,
env_type,
startup_script,
start_page,
} = verge ?? {};
const { theme_mode, language, tray_event, env_type, startup_script, start_page } = verge ?? {};
const configRef = useRef<DialogRef>(null);
const hotkeyRef = useRef<DialogRef>(null);
const miscRef = useRef<DialogRef>(null);
@@ -70,10 +78,12 @@ const SettingVergeBasic = ({ onError }: Props) => {
const onCopyClashEnv = useCallback(async () => {
await copyClashEnv();
showNotice("success", t("Copy Success"), 1000);
}, []);
}, [t]);
return (
<SettingList title={t("Verge Basic Setting")}>
<div>
<h3 className="text-lg font-medium mb-4">{t("Verge Basic Setting")}</h3>
<div className="space-y-1">
<ThemeViewer ref={themeRef} />
<ConfigViewer ref={configRef} />
<HotkeyViewer ref={hotkeyRef} />
@@ -82,25 +92,16 @@ const SettingVergeBasic = ({ onError }: Props) => {
<UpdateViewer ref={updateRef} />
<BackupViewer ref={backupRef} />
<SettingItem label={t("Language")}>
<GuardState
value={language ?? "en"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
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>
))}
<SettingRow label={<LabelWithIcon icon={Languages} text={t("Language")} />}>
<GuardState value={language ?? "en"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ language: e })} onGuard={(e) => patchVerge({ language: e })}>
<Select onValueChange={(value) => onChangeData({ language: value })} value={language}>
<SelectTrigger className="w-32 h-8"><SelectValue /></SelectTrigger>
<SelectContent>{languageOptions.map(({ code, label }) => (<SelectItem key={code} value={code}>{label}</SelectItem>))}</SelectContent>
</Select>
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem label={t("Theme Mode")}>
<SettingRow label={<LabelWithIcon icon={Palette} text={t("Theme Mode")} />}>
<GuardState
value={theme_mode}
onCatch={onError}
@@ -109,142 +110,71 @@ const SettingVergeBasic = ({ onError }: Props) => {
>
<ThemeModeSwitch />
</GuardState>
</SettingItem>
</SettingRow>
{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>
<SettingRow label={<LabelWithIcon icon={MousePointerClick} text={t("Tray Click Event")} />}>
<GuardState value={tray_event ?? "main_window"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ tray_event: e })} onGuard={(e) => patchVerge({ tray_event: e })}>
<Select onValueChange={(value) => onChangeData({ tray_event: value })} value={tray_event}>
<SelectTrigger className="w-40 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="main_window">{t("Show Main Window")}</SelectItem>
<SelectItem value="tray_menu">{t("Show Tray Menu")}</SelectItem>
<SelectItem value="system_proxy">{t("System Proxy")}</SelectItem>
<SelectItem value="tun_mode">{t("Tun Mode")}</SelectItem>
<SelectItem value="disable">{t("Disable")}</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingItem>
</SettingRow>
)}
<SettingItem
label={t("Copy Env Type")}
extra={
<TooltipIcon icon={ContentCopyRounded} onClick={onCopyClashEnv} />
}
>
<GuardState
value={env_type ?? (OS === "windows" ? "powershell" : "bash")}
onCatch={onError}
onFormat={(e: any) => e.target.value}
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>
<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 value={env_type ?? (OS === "windows" ? "powershell" : "bash")} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ env_type: e })} onGuard={(e) => patchVerge({ env_type: e })}>
<Select onValueChange={(value) => onChangeData({ env_type: value })} value={env_type}>
<SelectTrigger className="w-36 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="fish">Fish</SelectItem>
<SelectItem value="nushell">Nushell</SelectItem>
<SelectItem value="cmd">CMD</SelectItem>
<SelectItem value="powershell">PowerShell</SelectItem>
</SelectContent>
</Select>
</GuardState>
</SettingItem>
</SettingRow>
<SettingItem label={t("Start Page")}>
<GuardState
value={start_page ?? "/"}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ start_page: e })}
onGuard={(e) => patchVerge({ start_page: e })}
>
<Select size="small" sx={{ width: 140, "> div": { py: "7.5px" } }}>
{routers.map((page: { label: string; path: string }) => {
return (
<MenuItem key={page.path} value={page.path}>
{t(page.label)}
</MenuItem>
);
})}
<SettingRow label={<LabelWithIcon icon={Home} text={t("Start Page")} />}>
<GuardState value={start_page ?? "/"} onCatch={onError} onFormat={v => v} onChange={(e) => onChangeData({ start_page: e })} onGuard={(e) => patchVerge({ start_page: e })}>
<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>
</SettingItem>
</SettingRow>
<SettingItem label={t("Startup Script")}>
<GuardState
value={startup_script ?? ""}
onCatch={onError}
onFormat={(e: any) => e.target.value}
onChange={(e) => onChangeData({ startup_script: e })}
onGuard={(e) => patchVerge({ startup_script: e })}
>
<Input
value={startup_script}
disabled
disableUnderline
sx={{ width: 230 }}
endAdornment={
<>
<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>
<SettingRow label={<LabelWithIcon icon={FileTerminal} text={t("Startup Script")} />}>
<div className="flex items-center gap-2">
<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
onClick={() => themeRef.current?.open()}
label={t("Theme Setting")}
/>
<SettingItem
onClick={() => layoutRef.current?.open()}
label={t("Layout Setting")}
/>
<SettingItem
onClick={() => miscRef.current?.open()}
label={t("Miscellaneous")}
/>
<SettingItem
onClick={() => hotkeyRef.current?.open()}
label={t("Hotkey Setting")}
/>
</SettingList>
<SettingRow onClick={() => themeRef.current?.open()} label={<LabelWithIcon icon={SwatchBook} text={t("Theme Setting")} />} />
<SettingRow onClick={() => layoutRef.current?.open()} label={<LabelWithIcon icon={LayoutTemplate} text={t("Layout Setting")} />} />
<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>
</div>
);
};

View File

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

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;
const key = `${mode}-${!!selected}`;
// Определяем пропсы для нашего компонента.
// Он принимает все стандартные атрибуты для div, а также `selected`.
export interface TestBoxProps extends React.HTMLAttributes<HTMLDivElement> {
selected?: boolean;
}
const backgroundColor =
mode === "light" ? alpha(primary.main, 0.05) : alpha(primary.main, 0.08);
export const TestBox = React.forwardRef<HTMLDivElement, TestBoxProps>(
({ 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,
"light-false": text.secondary,
"dark-true": alpha(text.secondary, 0.65),
"dark-false": alpha(text.secondary, 0.65),
}[key]!;
// --- Стили по умолчанию (не выбран) ---
"bg-primary/5 text-muted-foreground",
"hover:bg-primary/10 hover:shadow-md",
const h2color = {
"light-true": primary.main,
"light-false": text.primary,
"dark-true": primary.main,
"dark-false": text.primary,
}[key]!;
// --- Стили для ВЫБРАННОГО состояния ---
// Используем data-атрибут для стилизации
"data-[selected=true]:bg-primary/20 data-[selected=true]:text-primary data-[selected=true]:shadow-lg",
return {
position: "relative",
width: "100%",
display: "block",
cursor: "pointer",
textAlign: "left",
borderRadius: 8,
boxShadow: theme.shadows[1],
padding: "8px 16px",
boxSizing: "border-box",
backgroundColor,
color,
"& h2": { color: h2color },
transition: "background-color 0.3s, box-shadow 0.3s",
"&:hover": {
backgroundColor:
mode === "light" ? alpha(primary.main, 0.1) : alpha(primary.main, 0.15),
boxShadow: theme.shadows[2],
},
};
});
// --- Дополнительные классы от пользователя ---
className
)}
{...props}
>
{children}
</div>
);
}
);
TestBox.displayName = "TestBox";

View File

@@ -3,16 +3,28 @@ import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useSortable } from "@dnd-kit/sortable";
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 { convertFileSrc } from "@tauri-apps/api/core";
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 {
id: string;
@@ -23,34 +35,21 @@ interface Props {
export const TestItem = (props: Props) => {
const { itemData, onEdit, onDelete: onDeleteItem } = props;
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.id,
});
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id });
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [delay, setDelay] = useState(-1);
const { uid, name, icon, url } = itemData;
const [iconCachePath, setIconCachePath] = useState("");
const { addListener } = useListen();
const onDelay = async () => {
setDelay(-2);
const onDelay = useLockFn(async () => {
setDelay(-2); // Состояние загрузки
const result = await cmdTestDelay(url);
setDelay(result);
};
});
useEffect(() => {
initIconCachePath();
}, [icon]);
const getFileName = (url: string) => url.substring(url.lastIndexOf("/") + 1);
async function initIconCachePath() {
if (icon && icon.trim().startsWith("http")) {
@@ -60,17 +59,9 @@ export const TestItem = (props: Props) => {
}
}
function getFileName(url: string) {
return url.substring(url.lastIndexOf("/") + 1);
}
const onEditTest = () => {
setAnchorEl(null);
onEdit();
};
useEffect(() => { initIconCachePath(); }, [icon]);
const onDelete = useLockFn(async () => {
setAnchorEl(null);
try {
onDeleteItem(uid);
} catch (err: any) {
@@ -79,167 +70,73 @@ export const TestItem = (props: Props) => {
});
const menu = [
{ label: "Edit", handler: onEditTest },
{ label: "Delete", handler: onDelete },
{ label: "Edit", handler: onEdit },
{ label: "Delete", handler: onDelete, isDestructive: true },
];
useEffect(() => {
let unlistenFn: UnlistenFn | null = null;
const setupListener = async () => {
if (unlistenFn) {
unlistenFn();
}
unlistenFn = await addListener("verge://test-all", () => {
onDelay();
});
if (unlistenFn) unlistenFn();
unlistenFn = await addListener("verge://test-all", onDelay);
};
setupListener();
return () => { unlistenFn?.(); };
}, [url, addListener, onDelay]);
return () => {
if (unlistenFn) {
console.log(
`TestItem for ${props.id} unmounting or url changed, cleaning up test-all listener.`,
);
unlistenFn();
}
};
}, [url, addListener, onDelay, props.id]);
return (
<Box
sx={{
position: "relative",
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? "calc(infinity)" : undefined,
}}
>
<TestBox
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
event.preventDefault();
}}
>
<Box
position="relative"
sx={{ cursor: "move" }}
ref={setNodeRef}
{...attributes}
{...listeners}
>
{icon && icon.trim() !== "" ? (
<Box sx={{ display: "flex", justifyContent: "center" }}>
{icon.trim().startsWith("http") && (
zIndex: isDragging ? 100 : undefined,
};
return (
<div style={style} ref={setNodeRef} {...attributes}>
<ContextMenu>
<ContextMenuTrigger>
<TestBox>
{/* Мы применяем `listeners` к иконке, чтобы за нее можно было таскать */}
<div {...listeners} className="flex h-12 cursor-move items-center justify-center">
{icon ? (
<img
src={iconCachePath === "" ? icon : iconCachePath}
height="40px"
src={icon.startsWith('data') ? icon : icon.startsWith('<svg') ? `data:image/svg+xml;base64,${btoa(icon)}` : (iconCachePath || icon)}
className="h-10"
alt={name}
/>
)}
{icon.trim().startsWith("data") && (
<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>
<Languages className="h-10 w-10 text-muted-foreground" />
)}
</div>
<Box sx={{ display: "flex", justifyContent: "center" }}>{name}</Box>
</Box>
<Divider sx={{ marginTop: "8px" }} />
<Box
sx={{
display: "flex",
justifyContent: "center",
marginTop: "8px",
color: "primary.main",
}}
>
{delay === -2 && (
<Widget>
<BaseLoading />
</Widget>
)}
<p className="mt-1 text-center text-sm font-semibold truncate" title={name}>{name}</p>
{delay === -1 && (
<Widget
className="the-check"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
sx={({ palette }) => ({
":hover": { bgcolor: alpha(palette.primary.main, 0.15) },
})}
>
{t("Test")}
</Widget>
)}
<Separator className="my-2" />
{delay >= 0 && (
// 显示延迟
<Widget
className="the-delay"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelay();
}}
color={delayManager.formatDelayColor(delay)}
sx={({ palette }) => ({
":hover": {
bgcolor: alpha(palette.primary.main, 0.15),
},
})}
<div
className="flex h-6 items-center justify-center text-sm font-medium"
onClick={(e) => { e.stopPropagation(); onDelay(); }}
>
{delayManager.formatDelay(delay)}
</Widget>
{delay === -2 ? (
<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>
)}
</Box>
</div>
</TestBox>
</ContextMenuTrigger>
<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();
}}
>
<ContextMenuContent>
{menu.map((item) => (
<MenuItem
key={item.label}
onClick={item.handler}
sx={{ minWidth: 120 }}
dense
>
<ContextMenuItem key={item.label} onClick={item.handler} className={item.isDestructive ? "text-destructive" : ""}>
{t(item.label)}
</MenuItem>
</ContextMenuItem>
))}
</Menu>
</Box>
</ContextMenuContent>
</ContextMenu>
</div>
);
};
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 { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import { TextField } from "@mui/material";
import { useForm } from "react-hook-form";
import { useVerge } from "@/hooks/use-verge";
import { BaseDialog } from "@/components/base";
import { nanoid } from "nanoid";
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 {
onChange: (uid: string, patch?: Partial<IVergeTestItem>) => void;
}
@@ -17,7 +37,6 @@ export interface TestViewerRef {
edit: (item: IVergeTestItem) => void;
}
// create or edit the test item
export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -25,146 +44,126 @@ export const TestViewer = forwardRef<TestViewerRef, Props>((props, ref) => {
const [loading, setLoading] = useState(false);
const { verge, patchVerge } = useVerge();
const testList = verge?.test_list ?? [];
const { control, watch, register, ...formIns } = useForm<IVergeTestItem>({
defaultValues: {
name: "",
icon: "",
url: "",
},
const form = useForm<IVergeTestItem>({
defaultValues: { name: "", icon: "", url: "" },
});
const { control, handleSubmit, reset, setValue } = form;
const patchTestList = async (uid: string, patch: Partial<IVergeTestItem>) => {
const newList = testList.map((x) => {
if (x.uid === uid) {
return { ...x, ...patch };
}
return x;
});
const newList = testList.map((x) => (x.uid === uid ? { ...x, ...patch } : x));
await patchVerge({ test_list: newList });
};
useImperativeHandle(ref, () => ({
create: () => {
reset({ name: "", icon: "", url: "" });
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
reset(item);
setOpenType("edit");
setOpen(true);
},
}));
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
handleSubmit(async (formData) => {
setLoading(true);
try {
if (!form.name) throw new Error("`Name` should not be null");
if (!form.url) throw new Error("`Url` should not be null");
if (!formData.name) throw new Error("`Name` should not be null");
if (!formData.url) throw new Error("`Url` should not be null");
let newList;
let uid;
if (formData.icon && formData.icon.startsWith("<svg")) {
// --- ИСПРАВЛЕНИЕ ЗДЕСЬ ---
// Удаляем комментарии из SVG, используя правильное регулярное выражение
formData.icon = formData.icon.replace(/<!--[\s\S]*?-->/g, "");
// --- КОНЕЦ ИСПРАВЛЕНИЯ ---
if (form.icon && form.icon.startsWith("<svg")) {
// 移除 icon 中的注释
if (form.icon) {
form.icon = form.icon.replace(/<!--[\s\S]*?-->/g, "");
}
const doc = new DOMParser().parseFromString(
form.icon,
"image/svg+xml",
);
const doc = new DOMParser().parseFromString(formData.icon, "image/svg+xml");
if (doc.querySelector("parsererror")) {
throw new Error("`Icon`svg format error");
}
}
if (openType === "new") {
uid = nanoid();
const item = { ...form, uid };
newList = [...testList, item];
const uid = nanoid();
const item = { ...formData, uid };
const newList = [...testList, item];
await patchVerge({ test_list: newList });
props.onChange(uid);
} else {
if (!form.uid) throw new Error("UID not found");
uid = form.uid;
await patchTestList(uid, form);
props.onChange(uid, form);
if (!formData.uid) throw new Error("UID not found");
await patchTestList(formData.uid, formData);
props.onChange(formData.uid, formData);
}
setOpen(false);
setLoading(false);
setTimeout(() => formIns.reset(), 500);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
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 (
<BaseDialog
open={open}
title={openType === "new" ? t("Create Test") : t("Edit Test")}
contentSx={{ width: 375, pb: 0, maxHeight: "80%" }}
okBtn={t("Save")}
cancelBtn={t("Cancel")}
onClose={handleClose}
onCancel={handleClose}
onOk={handleOk}
loading={loading}
>
<Controller
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Test") : t("Edit Test")}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleOk} className="space-y-4">
<FormField
control={control}
name="name"
control={control}
rules={{ required: true }}
render={({ field }) => (
<TextField {...text} {...field} label={t("Name")} />
<FormItem>
<FormLabel>{t("Name")}</FormLabel>
<FormControl><Input {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Controller
<FormField
control={control}
name="icon"
control={control}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={5}
label={t("Icon")}
/>
<FormItem>
<FormLabel>{t("Icon")}</FormLabel>
<FormControl><Textarea {...field} rows={4} placeholder="<svg>...</svg> or http(s)://..." /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Controller
<FormField
control={control}
name="url"
control={control}
rules={{ required: true }}
render={({ field }) => (
<TextField
{...text}
{...field}
multiline
maxRows={3}
label={t("Test URL")}
/>
<FormItem>
<FormLabel>{t("Test URL")}</FormLabel>
<FormControl><Textarea {...field} rows={3} placeholder="https://www.google.com" /></FormControl>
<FormMessage />
</FormItem>
)}
/>
</BaseDialog>
<button type="submit" className="hidden" />
</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