New Interface (initial commit)
This commit is contained in:
21
components.json
Normal file
21
components.json
Normal 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
6
lib/utils.ts
Normal 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));
|
||||
}
|
||||
108
package.json
108
package.json
@@ -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
2120
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
|
||||
@@ -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 />;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
265
src/components/home/proxy-selectors.tsx
Normal file
265
src/components/home/proxy-selectors.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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%" }} />;
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal 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 };
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal 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 };
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal 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 };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
182
src/components/ui/command.tsx
Normal file
182
src/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
250
src/components/ui/context-menu.tsx
Normal file
250
src/components/ui/context-menu.tsx
Normal 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,
|
||||
};
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal 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
166
src/components/ui/form.tsx
Normal 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,
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 };
|
||||
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 };
|
||||
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal 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 };
|
||||
29
src/components/ui/progress.tsx
Normal file
29
src/components/ui/progress.tsx
Normal 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 };
|
||||
183
src/components/ui/select.tsx
Normal file
183
src/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
26
src/components/ui/separator.tsx
Normal file
26
src/components/ui/separator.tsx
Normal 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
137
src/components/ui/sheet.tsx
Normal 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
Reference in New Issue
Block a user