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

View File

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

View File

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