New Interface (initial commit)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
{/* Нижняя строка: Кнопка логов или заглушка для сохранения высоты */}
|
||||
<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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user