New Interface (initial commit)

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,19 @@
import { Fragment } from "react";
import { useTranslation } from "react-i18next";
// Новые импорты
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogHeader,
DialogTitle,
Divider,
Typography,
} from "@mui/material";
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge, badgeVariants } from "@/components/ui/badge";
import { BaseEmpty } from "@/components/base";
import { cn } from "@root/lib/utils";
interface Props {
open: boolean;
@@ -20,50 +23,47 @@ interface Props {
export const LogViewer = (props: Props) => {
const { open, logInfo, onClose } = props;
const { t } = useTranslation();
// Вспомогательная функция для определения варианта Badge
const getLogLevelVariant = (level: string): "destructive" | "secondary" => {
return level === "error" || level === "exception" ? "destructive" : "secondary";
};
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t("Script Console")}</DialogTitle>
<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 }}
/>
{log}
</Typography>
<Divider sx={{ my: 0.5 }} />
</Fragment>
))}
{/* Контейнер для логов с прокруткой */}
<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}
</p>
</div>
</div>
))
) : (
<BaseEmpty />
)}
</div>
{logInfo.length === 0 && <BaseEmpty />}
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline">{t("Close")}</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Close")}
</Button>
</DialogActions>
</Dialog>
);
};

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,29 +1,42 @@
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { useLockFn } from "ahooks";
import { useTranslation } from "react-i18next";
import { useForm, Controller } from "react-hook-form";
import {
Box,
FormControl,
InputAdornment,
InputLabel,
MenuItem,
Select,
styled,
TextField,
} from "@mui/material";
import { useForm } from "react-hook-form";
import { createProfile, patchProfile } from "@/services/cmds";
import { BaseDialog, Switch } from "@/components/base";
import { version } from "@root/package.json";
import { FileInput } from "./file-input";
import { useProfiles } from "@/hooks/use-profiles";
import { showNotice } from "@/services/noticeService";
import { version } from "@root/package.json";
// --- Новые импорты ---
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Loader2 } from "lucide-react";
interface Props {
onChange: (isActivating?: boolean) => void;
@@ -34,361 +47,253 @@ export interface ProfileViewerRef {
edit: (item: IProfileItem) => void;
}
// create or edit the profile
// remote / local
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();
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();
const fileDataRef = useRef<string | null>(null);
// file input
const fileDataRef = useRef<string | null>(null);
const { control, watch, register, ...formIns } = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
},
const form = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
danger_accept_invalid_certs: false,
},
});
},
});
useImperativeHandle(ref, () => ({
create: () => {
setOpenType("new");
setOpen(true);
},
edit: (item) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
});
}
setOpenType("edit");
setOpen(true);
},
}));
const { control, watch, handleSubmit, reset, setValue } = form;
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
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) => {
reset(item);
fileDataRef.current = null;
setOpenType("edit");
setOpen(true);
},
}));
useEffect(() => {
if (selfProxy) formIns.setValue("option.with_proxy", false);
}, [selfProxy]);
const selfProxy = watch("option.self_proxy");
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (withProxy) formIns.setValue("option.self_proxy", false);
}, [withProxy]);
useEffect(() => {
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
if (form.option?.timeout_seconds) {
form.option.timeout_seconds = +form.option.timeout_seconds;
useEffect(() => {
if (withProxy) setValue("option.self_proxy", false);
}, [withProxy, setValue]);
const handleOk = useLockFn(
handleSubmit(async (form) => {
if (form.option?.timeout_seconds) {
form.option.timeout_seconds = +form.option.timeout_seconds;
}
setLoading(true);
try {
if (!form.type) throw new Error("`Type` should not be null");
if (form.type === "remote" && !form.url) {
throw new Error("The URL should not be null");
}
setLoading(true);
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 {
delete form.option?.update_interval;
}
if (form.option?.user_agent === "") {
delete form.option.user_agent;
}
// 处理表单数据
if (form.option?.update_interval) {
form.option.update_interval = +form.option.update_interval;
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
const isActivating = isUpdate && form.uid === (profiles?.current ?? "");
const originalOptions = { with_proxy: form.option?.with_proxy, self_proxy: form.option?.self_proxy };
if (!isRemote) {
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
delete form.option?.update_interval;
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
}
if (form.option?.user_agent === "") {
delete form.option.user_agent;
}
const name = form.name || `${form.type} file`;
const item = { ...form, name };
const isRemote = form.type === "remote";
const isUpdate = openType === "edit";
// 判断是否是当前激活的配置
const isActivating =
isUpdate && form.uid === (profiles?.current ?? "");
// 保存原始代理设置以便回退成功后恢复
const originalOptions = {
with_proxy: form.option?.with_proxy,
self_proxy: form.option?.self_proxy,
};
// 执行创建或更新操作,本地配置不需要回退机制
if (!isRemote) {
} else {
try {
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, item);
}
} else {
// 远程配置使用回退机制
try {
// 尝试正常操作
if (openType === "new") {
await createProfile(item, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
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,
},
};
// 使用自身代理再次尝试
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"),
);
} catch (err) {
showNotice("info", t("Profile creation failed, retrying with Clash proxy..."));
const retryItem = { ...item, option: { ...item.option, with_proxy: false, self_proxy: true } };
if (openType === "new") {
await createProfile(retryItem, fileDataRef.current);
} else {
if (!form.uid) throw new Error("UID not found");
await patchProfile(form.uid, retryItem);
await patchProfile(form.uid, { option: originalOptions });
}
showNotice("success", t("Profile creation succeeded with Clash proxy"));
}
// 成功后的操作
setOpen(false);
setTimeout(() => formIns.reset(), 500);
fileDataRef.current = null;
// 优化UI先关闭异步通知父组件
setTimeout(() => {
props.onChange(isActivating);
}, 0);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
}
}),
);
const handleClose = () => {
try {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
} catch {}
};
props.onChange(isActivating);
} catch (err: any) {
showNotice("error", err.message || err.toString());
} finally {
setLoading(false);
}
}),
);
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";
const formType = watch("type");
const isRemote = formType === "remote";
const isLocal = formType === "local";
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{openType === "new" ? t("Create Profile") : t("Edit Profile")}</DialogTitle>
</DialogHeader>
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>
</Select>
</FormControl>
)}
/>
<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>
<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>
),
},
}}
/>
)}
/>
</>
)}
{(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>
),
},
}}
/>
{isRemote && (
<>
<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>
)}/>
</>
)}
/>
)}
{isLocal && openType === "new" && (
<FileInput
onChange={(file, val) => {
formIns.setValue("name", formIns.getValues("name") || file.name);
fileDataRef.current = val;
}}
/>
)}
{isLocal && openType === "new" && (
<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>
)}
/>
{isRemote && (
<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>
);
},
);
const StyledBox = styled(Box)(() => ({
margin: "8px 0 8px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}));
<DialogFooter>
<DialogClose asChild><Button type="button" variant="outline">{t("Cancel")}</Button></DialogClose>
<Button type="button" onClick={handleOk} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
});

View File

@@ -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) {
let activeIndex = 0;
let overIndex = 0;
prependSeq.forEach((item, index) => {
if (item.name === active.id) {
activeIndex = index;
}
if (item.name === over.id) {
overIndex = index;
}
});
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
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;
});
setPrependSeq(reorder(prependSeq, activeIndex, overIndex));
}
};
const onAppendDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over) {
if (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;
}
});
setAppendSeq(reorder(appendSeq, activeIndex, overIndex));
}
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;
});
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,40 +226,39 @@ 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;
let obj = yaml.load(currData) as {
prepend: [];
append: [];
delete: [];
} | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
if (currData === "" || visualization !== true) return;
try {
let obj = yaml.load(currData) as {
prepend: [];
append: [];
delete: [];
} | null;
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,242 +301,202 @@ 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>
<Button
variant="contained"
size="small"
onClick={() => {
setVisualization((prev) => !prev);
}}
>
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</Box>
</Box>
}
</DialogTitle>
<DialogContent
sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
>
{visualization ? (
<>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
<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="outline"
size="sm"
onClick={() => setVisualization((prev) => !prev)}
>
<Box
sx={{
height: "calc(100% - 80px)",
overflowY: "auto",
}}
>
<Item>
<TextField
autoComplete="new-password"
placeholder={t("Use newlines for multiple uri")}
fullWidth
rows={9}
multiline
size="small"
onChange={(e) => setProxyUri(e.target.value)}
{visualization ? t("Advanced") : t("Visualization")}
</Button>
</div>
</DialogHeader>
<div className="flex-1 min-h-0">
{visualization ? (
<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")}
className="flex-1"
value={proxyUri}
onChange={(e) => setProxyUri(e.target.value)}
/>
<div className="flex flex-col gap-2">
<Button
onClick={() =>
handleParseAsync((proxies) =>
setPrependSeq((prev) => [...proxies, ...prev]),
)
}
>
<ArrowUpToLine className="mr-2 h-4 w-4" />
{t("Prepend Proxy")}
</Button>
<Button
onClick={() =>
handleParseAsync((proxies) =>
setAppendSeq((prev) => [...prev, ...proxies]),
)
}
>
<ArrowDownToLine className="mr-2 h-4 w-4" />
{t("Append Proxy")}
</Button>
</div>
</div>
<Separator orientation="vertical" />
<div className="w-2/3 flex flex-col">
<BaseSearchBox
onSearch={(matcher) => setMatch(() => matcher)}
/>
<div className="flex-1 min-h-0 mt-2 rounded-md border">
<Virtuoso
className="h-full"
totalCount={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
itemContent={(index) => {
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => x.name)}
>
{filteredPrependSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="prepend"
proxy={item}
onDelete={() =>
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
)
}
/>
))}
</SortableContext>
</DndContext>
);
} else if (index < filteredProxyList.length + shift) {
const newIndex = index - shift;
const currentProxy = filteredProxyList[newIndex];
return (
<EditorProxyItem
key={currentProxy.name}
id={currentProxy.name}
p_type={
deleteSeq.includes(currentProxy.name)
? "delete"
: "original"
}
proxy={currentProxy}
onDelete={() => {
if (deleteSeq.includes(currentProxy.name)) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== currentProxy.name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
currentProxy.name,
]);
}
}}
/>
);
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => x.name)}
>
{filteredAppendSeq.map((item) => (
<EditorProxyItem
key={item.name}
id={item.name}
p_type="append"
proxy={item}
onDelete={() =>
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
)
}
/>
))}
</SortableContext>
</DndContext>
);
}
}}
/>
</Item>
</Box>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignTopRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setPrependSeq((prev) => [...proxies, ...prev]);
});
}}
>
{t("Prepend Proxy")}
</Button>
</Item>
<Item>
<Button
fullWidth
variant="contained"
startIcon={<VerticalAlignBottomRounded />}
onClick={() => {
handleParseAsync((proxies) => {
setAppendSeq((prev) => [...prev, ...proxies]);
});
}}
>
{t("Append Proxy")}
</Button>
</Item>
</List>
<List
sx={{
width: "50%",
padding: "0 10px",
}}
>
<BaseSearchBox onSearch={(match) => setMatch(() => match)} />
<Virtuoso
style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
totalCount={
filteredProxyList.length +
(filteredPrependSeq.length > 0 ? 1 : 0) +
(filteredAppendSeq.length > 0 ? 1 : 0)
}
increaseViewportBy={256}
itemContent={(index) => {
let shift = filteredPrependSeq.length > 0 ? 1 : 0;
if (filteredPrependSeq.length > 0 && index === 0) {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onPrependDragEnd}
>
<SortableContext
items={filteredPrependSeq.map((x) => {
return x.name;
})}
>
{filteredPrependSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="prepend"
proxy={item}
onDelete={() => {
setPrependSeq(
prependSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
} else if (index < filteredProxyList.length + shift) {
let newIndex = index - shift;
return (
<ProxyItem
key={`${filteredProxyList[newIndex].name}-${index}`}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? "delete"
: "original"
}
proxy={filteredProxyList[newIndex]}
onDelete={() => {
if (
deleteSeq.includes(filteredProxyList[newIndex].name)
) {
setDeleteSeq(
deleteSeq.filter(
(v) => v !== filteredProxyList[newIndex].name,
),
);
} else {
setDeleteSeq((prev) => [
...prev,
filteredProxyList[newIndex].name,
]);
}
}}
/>
);
} else {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onAppendDragEnd}
>
<SortableContext
items={filteredAppendSeq.map((x) => {
return x.name;
})}
>
{filteredAppendSeq.map((item, index) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
type="append"
proxy={item}
onDelete={() => {
setAppendSeq(
appendSeq.filter(
(v) => v.name !== item.name,
),
);
}}
/>
);
})}
</SortableContext>
</DndContext>
);
}
</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,
minimap: {
enabled: document.documentElement.clientWidth >= 1500,
},
mouseWheelZoom: true,
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,
}}
onChange={(value) => setCurrData(value)}
/>
</List>
</>
) : (
<MonacoEditor
height="100%"
language="yaml"
value={currData}
theme={themeMode === "light" ? "vs" : "vs-dark"}
options={{
tabSize: 2, // 根据语言类型设置缩进大小
minimap: {
enabled: document.documentElement.clientWidth >= 1500, // 超过一定宽度显示minimap滚动条
},
mouseWheelZoom: true, // 按住Ctrl滚轮调节缩放比例
quickSuggestions: {
strings: true, // 字符串类型的建议
comments: true, // 注释类型的建议
other: true, // 其他类型的建议
},
padding: {
top: 33, // 顶部padding防止遮挡snippets
},
fontFamily: `Fira Code, JetBrains Mono, Roboto Mono, "Source Code Pro", Consolas, Menlo, Monaco, monospace, "Courier New", "Apple Color Emoji"${
getSystem() === "windows" ? ", twemoji mozilla" : ""
}`,
fontLigatures: false, // 连字符
smoothScrolling: true, // 平滑滚动
}}
onChange={(value) => setCurrData(value)}
/>
)}
</div>
)}
</div>
<DialogFooter className="mt-4">
<DialogClose asChild>
<Button type="button" variant="outline">
{t("Cancel")}
</Button>
</DialogClose>
<Button type="button" onClick={handleSave}>
{t("Save")}
</Button>
</DialogFooter>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="outlined">
{t("Cancel")}
</Button>
<Button onClick={handleSave} variant="contained">
{t("Save")}
</Button>
</DialogActions>
</Dialog>
);
};
const Item = styled(ListItem)(() => ({
padding: "5px 2px",
}));

View File

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

View File

@@ -1,25 +1,44 @@
import {
Box,
IconButton,
ListItem,
ListItemText,
alpha,
styled,
} from "@mui/material";
import { DeleteForeverRounded, UndoRounded } from "@mui/icons-material";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@root/lib/utils";
// Новые импорты
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { GripVertical, Trash2, Undo2 } from "lucide-react";
interface Props {
type: "prepend" | "original" | "delete" | "append";
ruleRaw: string;
onDelete: () => void;
}
export const RuleItem = (props: Props) => {
let { type, ruleRaw, onDelete } = props;
const sortable = type === "prepend" || type === "append";
const rule = ruleRaw.replace(",no-resolve", "");
// Определяем стили для каждого типа элемента
const typeStyles = {
original: "bg-secondary/50",
delete: "bg-destructive/20 text-muted-foreground line-through",
prepend: "bg-green-500/20",
append: "bg-green-500/20",
};
// Вспомогательная функция для цвета политики прокси
const PROXY_COLOR_CLASSES = ["text-sky-500", "text-violet-500", "text-amber-500", "text-lime-500", "text-emerald-500"];
const getProxyColorClass = (proxyName: string): string => {
if (proxyName === "REJECT" || proxyName === "REJECT-DROP") return "text-destructive";
if (proxyName === "DIRECT") return "text-primary";
let sum = 0;
for (let i = 0; i < proxyName.length; i++) sum += proxyName.charCodeAt(i);
return PROXY_COLOR_CLASSES[sum % PROXY_COLOR_CLASSES.length];
};
export const RuleItem = (props: Props) => {
const { type, ruleRaw, onDelete } = props;
// Drag-and-drop будет работать только для 'prepend' и 'append' типов
const isSortable = type === "prepend" || type === "append";
// Логика парсинга строки правила остается без изменений
const rule = ruleRaw.replace(",no-resolve", "");
const ruleType = rule.match(/^[^,]+/)?.[0] ?? "";
const proxyPolicy = rule.match(/[^,]+$/)?.[0] ?? "";
const ruleContent = rule.slice(ruleType.length + 1, -proxyPolicy.length - 1);
@@ -31,112 +50,55 @@ export const RuleItem = (props: Props) => {
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: ruleRaw })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
} = useSortable({ id: ruleRaw, 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={ruleContent || "-"}
sx={{ textDecoration: type === "delete" ? "line-through" : "" }}
>
{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" }}>
{proxyPolicy}
</StyledSubtitle>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
}}
/>
<IconButton onClick={onDelete}>
{type === "delete" ? <UndoRounded /> : <DeleteForeverRounded />}
</IconButton>
</ListItem>
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 || "-"}
</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}
</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",
}));

File diff suppressed because it is too large Load Diff