Browse Source

feat: support visual edit for proxy group

MystiPanda 11 months ago
parent
commit
12a80f35fe

+ 141 - 0
src/components/profile/group-item.tsx

@@ -0,0 +1,141 @@
+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 { useThemeMode } from "@/services/states";
+interface Props {
+  type: "prepend" | "original" | "delete" | "append";
+  group: IProxyGroupConfig;
+  onDelete: () => void;
+}
+
+export const GroupItem = (props: Props) => {
+  let { type, group, onDelete } = props;
+  const themeMode = useThemeMode();
+  const itembackgroundcolor = themeMode === "dark" ? "#282A36" : "#ffffff";
+  const sortable = type === "prepend" || type === "append";
+
+  const { attributes, listeners, setNodeRef, transform, transition } = sortable
+    ? useSortable({ id: group.name })
+    : {
+        attributes: {},
+        listeners: {},
+        setNodeRef: null,
+        transform: null,
+        transition: null,
+      };
+  return (
+    <ListItem
+      dense
+      sx={({ palette }) => ({
+        background:
+          type === "original"
+            ? itembackgroundcolor
+            : 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,
+      })}
+    >
+      {group.icon && group.icon?.trim().startsWith("http") && (
+        <img
+          src={group.icon}
+          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
+        {...attributes}
+        {...listeners}
+        ref={setNodeRef}
+        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>
+  );
+};
+
+const StyledPrimary = styled("span")`
+  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",
+}));

+ 814 - 0
src/components/profile/groups-editor-viewer.tsx

@@ -0,0 +1,814 @@
+import { ReactNode, useEffect, useMemo, useState } from "react";
+import { useLockFn } from "ahooks";
+import yaml from "js-yaml";
+import { useTranslation } from "react-i18next";
+import {
+  DndContext,
+  closestCenter,
+  KeyboardSensor,
+  PointerSensor,
+  useSensor,
+  useSensors,
+  DragEndEvent,
+} from "@dnd-kit/core";
+import {
+  SortableContext,
+  sortableKeyboardCoordinates,
+} from "@dnd-kit/sortable";
+import {
+  Autocomplete,
+  Box,
+  Button,
+  Dialog,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+  List,
+  ListItem,
+  ListItemText,
+  TextField,
+  styled,
+} from "@mui/material";
+import { GroupItem } from "@/components/profile/group-item";
+import { readProfileFile, saveProfileFile } from "@/services/cmds";
+import { Notice, Switch } from "@/components/base";
+import getSystem from "@/utils/get-system";
+import { BaseSearchBox } from "../base/base-search-box";
+import { Virtuoso } from "react-virtuoso";
+import MonacoEditor from "react-monaco-editor";
+import { useThemeMode } from "@/services/states";
+import { Controller, useForm } from "react-hook-form";
+
+interface Props {
+  proxiesUid: string;
+  mergeUid: string;
+  profileUid: string;
+  property: string;
+  open: boolean;
+  onClose: () => void;
+  onSave?: (prev?: string, curr?: string) => void;
+}
+
+const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
+
+export const GroupsEditorViewer = (props: Props) => {
+  const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } =
+    props;
+  const { t } = useTranslation();
+  const themeMode = useThemeMode();
+  const [prevData, setPrevData] = useState("");
+  const [currData, setCurrData] = useState("");
+  const [visible, setVisible] = useState(true);
+  const [match, setMatch] = useState(() => (_: string) => true);
+
+  const { control, watch, register, ...formIns } = useForm<IProxyGroupConfig>({
+    defaultValues: {
+      type: "select",
+      name: "",
+      lazy: true,
+    },
+  });
+  const [groupList, setGroupList] = useState<IProxyGroupConfig[]>([]);
+  const [proxyPolicyList, setProxyPolicyList] = useState<string[]>([]);
+  const [proxyProviderList, setProxyProviderList] = useState<string[]>([]);
+  const [prependSeq, setPrependSeq] = useState<IProxyGroupConfig[]>([]);
+  const [appendSeq, setAppendSeq] = useState<IProxyGroupConfig[]>([]);
+  const [deleteSeq, setDeleteSeq] = useState<string[]>([]);
+
+  const filteredGroupList = useMemo(
+    () => groupList.filter((group) => match(group.name)),
+    [groupList, match]
+  );
+
+  const sensors = useSensors(
+    useSensor(PointerSensor),
+    useSensor(KeyboardSensor, {
+      coordinateGetter: sortableKeyboardCoordinates,
+    })
+  );
+  const reorder = (
+    list: IProxyGroupConfig[],
+    startIndex: number,
+    endIndex: number
+  ) => {
+    const result = Array.from(list);
+    const [removed] = result.splice(startIndex, 1);
+    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));
+      }
+    }
+  };
+  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));
+      }
+    }
+  };
+  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 (visible !== true) return;
+
+    let obj = yaml.load(currData) as {
+      prepend: [];
+      append: [];
+      delete: [];
+    } | null;
+    setPrependSeq(obj?.prepend || []);
+    setAppendSeq(obj?.append || []);
+    setDeleteSeq(obj?.delete || []);
+  }, [visible]);
+
+  useEffect(() => {
+    if (prependSeq && appendSeq && deleteSeq)
+      setCurrData(
+        yaml.dump({ prepend: prependSeq, append: appendSeq, delete: deleteSeq })
+      );
+  }, [prependSeq, appendSeq, deleteSeq]);
+
+  const fetchProxyPolicy = async () => {
+    let data = await readProfileFile(profileUid);
+    let proxiesData = await readProfileFile(proxiesUid);
+    let originGroupsObj = yaml.load(data) as {
+      "proxy-groups": IProxyGroupConfig[];
+    } | null;
+
+    let originProxiesObj = yaml.load(data) as { proxies: [] } | null;
+    let originProxies = originProxiesObj?.proxies || [];
+    let moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null;
+    let morePrependProxies = moreProxiesObj?.prepend || [];
+    let moreAppendProxies = moreProxiesObj?.append || [];
+    let moreDeleteProxies =
+      moreProxiesObj?.delete || ([] as string[] | { name: string }[]);
+
+    let proxies = morePrependProxies.concat(
+      originProxies.filter((proxy: any) => {
+        if (proxy.name) {
+          return !moreDeleteProxies.includes(proxy.name);
+        } else {
+          return !moreDeleteProxies.includes(proxy);
+        }
+      }),
+      moreAppendProxies
+    );
+
+    setProxyPolicyList(
+      builtinProxyPolicies.concat(
+        prependSeq.map((group: IProxyGroupConfig) => group.name),
+        originGroupsObj?.["proxy-groups"]
+          .map((group: IProxyGroupConfig) => group.name)
+          .filter((name) => !deleteSeq.includes(name)) || [],
+        appendSeq.map((group: IProxyGroupConfig) => group.name),
+        proxies.map((proxy: any) => proxy.name)
+      )
+    );
+  };
+  const fetchProfile = async () => {
+    let data = await readProfileFile(profileUid);
+    let mergeData = await readProfileFile(mergeUid);
+    let globalMergeData = await readProfileFile("Merge");
+
+    let originGroupsObj = yaml.load(data) as {
+      "proxy-groups": IProxyGroupConfig[];
+    } | null;
+
+    let originProviderObj = yaml.load(data) as { "proxy-providers": {} } | null;
+    let originProvider = originProviderObj?.["proxy-providers"] || {};
+
+    let moreProviderObj = yaml.load(mergeData) as {
+      "proxy-providers": {};
+    } | null;
+    let moreProvider = moreProviderObj?.["proxy-providers"] || {};
+
+    let globalProviderObj = yaml.load(globalMergeData) as {
+      "proxy-providers": {};
+    } | null;
+    let globalProvider = globalProviderObj?.["proxy-providers"] || {};
+
+    let provider = Object.assign(
+      {},
+      originProvider,
+      moreProvider,
+      globalProvider
+    );
+
+    setProxyProviderList(Object.keys(provider));
+    setGroupList(originGroupsObj?.["proxy-groups"] || []);
+  };
+  useEffect(() => {
+    fetchProxyPolicy();
+  }, [prependSeq, appendSeq, deleteSeq]);
+  useEffect(() => {
+    if (!open) return;
+    fetchContent();
+    fetchProxyPolicy();
+    fetchProfile();
+  }, [open]);
+
+  const validateGroup = () => {
+    let group = formIns.getValues();
+    if (group.name === "") {
+      throw new Error(t("Group Name Cannot Be Empty"));
+    }
+  };
+
+  const handleSave = useLockFn(async () => {
+    try {
+      await saveProfileFile(property, currData);
+      onSave?.(prevData, currData);
+      onClose();
+    } catch (err: any) {
+      Notice.error(err.message || err.toString());
+    }
+  });
+
+  return (
+    <Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
+      <DialogTitle>
+        {
+          <Box display="flex" justifyContent="space-between">
+            {t("Edit Groups")}
+            <Box>
+              <Button
+                variant="contained"
+                size="small"
+                onClick={() => {
+                  setVisible((prev) => !prev);
+                }}
+              >
+                {visible ? t("Advanced") : t("Visible")}
+              </Button>
+            </Box>
+          </Box>
+        }
+      </DialogTitle>
+
+      <DialogContent
+        sx={{ display: "flex", width: "auto", height: "calc(100vh - 185px)" }}
+      >
+        {visible ? (
+          <>
+            <List
+              sx={{
+                width: "50%",
+                padding: "0 10px",
+              }}
+            >
+              <Box
+                sx={{
+                  height: "calc(100% - 80px)",
+                  overflowY: "auto",
+                }}
+              >
+                <Controller
+                  name="type"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Group Type")} />
+                      <Autocomplete
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        options={[
+                          "select",
+                          "url-test",
+                          "fallback",
+                          "load-balance",
+                          "relay",
+                        ]}
+                        value={field.value}
+                        onChange={(_, value) => value && field.onChange(value)}
+                        renderInput={(params) => <TextField {...params} />}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="name"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Group Name")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                        required={true}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="icon"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Icon")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="proxies"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Use Proxies")} />
+                      <Autocomplete
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        multiple
+                        options={proxyPolicyList}
+                        onChange={(_, value) => value && field.onChange(value)}
+                        renderInput={(params) => <TextField {...params} />}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="use"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Use Provider")} />
+                      <Autocomplete
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        multiple
+                        options={proxyProviderList}
+                        onChange={(_, value) => value && field.onChange(value)}
+                        renderInput={(params) => <TextField {...params} />}
+                      />
+                    </Item>
+                  )}
+                />
+
+                <Controller
+                  name="url"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Health Check Url")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="interval"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Interval")} />
+                      <TextField
+                        autoComplete="off"
+                        type="number"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="timeout"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Timeout")} />
+                      <TextField
+                        autoComplete="off"
+                        type="number"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="max-failed-times"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Max Failed Times")} />
+                      <TextField
+                        autoComplete="off"
+                        type="number"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="interface-name"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Interface Name")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="routing-mark"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Routing Mark")} />
+                      <TextField
+                        autoComplete="off"
+                        type="number"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="filter"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Filter")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="exclude-filter"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Exclude Filter")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="exclude-type"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Exclude Type")} />
+                      <TextField
+                        autoComplete="off"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="expected-status"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Expected Status")} />
+                      <TextField
+                        autoComplete="off"
+                        type="number"
+                        size="small"
+                        sx={{ minWidth: "240px" }}
+                        {...field}
+                      />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="include-all"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Include All")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="include-all-proxies"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Include All Proxies")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="include-all-providers"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Include All Providers")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="lazy"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Lazy")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="disable-udp"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Disable UDP")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+                <Controller
+                  name="hidden"
+                  control={control}
+                  render={({ field }) => (
+                    <Item>
+                      <ListItemText primary={t("Hidden")} />
+                      <Switch checked={field.value} {...field} />
+                    </Item>
+                  )}
+                />
+              </Box>
+              <Item>
+                <Button
+                  fullWidth
+                  variant="contained"
+                  onClick={() => {
+                    try {
+                      validateGroup();
+                      for (const item of prependSeq) {
+                        if (item.name === formIns.getValues().name) {
+                          throw new Error(t("Group Name Already Exists"));
+                        }
+                      }
+                      setPrependSeq([...prependSeq, formIns.getValues()]);
+                    } catch (err: any) {
+                      Notice.error(err.message || err.toString());
+                    }
+                  }}
+                >
+                  {t("Prepend Group")}
+                </Button>
+              </Item>
+              <Item>
+                <Button
+                  fullWidth
+                  variant="contained"
+                  onClick={() => {
+                    try {
+                      validateGroup();
+                      for (const item of appendSeq) {
+                        if (item.name === formIns.getValues().name) {
+                          throw new Error(t("Group Name Already Exists"));
+                        }
+                      }
+                      setAppendSeq([...appendSeq, formIns.getValues()]);
+                    } catch (err: any) {
+                      Notice.error(err.message || err.toString());
+                    }
+                  }}
+                >
+                  {t("Append Group")}
+                </Button>
+              </Item>
+            </List>
+
+            <List
+              sx={{
+                width: "50%",
+                padding: "0 10px",
+              }}
+            >
+              <BaseSearchBox
+                matchCase={false}
+                onSearch={(match) => setMatch(() => match)}
+              />
+              <Virtuoso
+                style={{ height: "calc(100% - 24px)", marginTop: "8px" }}
+                totalCount={
+                  filteredGroupList.length +
+                  (prependSeq.length > 0 ? 1 : 0) +
+                  (appendSeq.length > 0 ? 1 : 0)
+                }
+                increaseViewportBy={256}
+                itemContent={(index) => {
+                  let shift = prependSeq.length > 0 ? 1 : 0;
+                  if (prependSeq.length > 0 && index === 0) {
+                    return (
+                      <DndContext
+                        sensors={sensors}
+                        collisionDetection={closestCenter}
+                        onDragEnd={onPrependDragEnd}
+                      >
+                        <SortableContext
+                          items={prependSeq.map((x) => {
+                            return x.name;
+                          })}
+                        >
+                          {prependSeq.map((item, index) => {
+                            return (
+                              <GroupItem
+                                key={`${item.name}-${index}`}
+                                type="prepend"
+                                group={item}
+                                onDelete={() => {
+                                  setPrependSeq(
+                                    prependSeq.filter(
+                                      (v) => v.name !== item.name
+                                    )
+                                  );
+                                }}
+                              />
+                            );
+                          })}
+                        </SortableContext>
+                      </DndContext>
+                    );
+                  } else if (index < filteredGroupList.length + shift) {
+                    let newIndex = index - shift;
+                    return (
+                      <GroupItem
+                        key={`${filteredGroupList[newIndex].name}-${index}`}
+                        type={
+                          deleteSeq.includes(filteredGroupList[newIndex].name)
+                            ? "delete"
+                            : "original"
+                        }
+                        group={filteredGroupList[newIndex]}
+                        onDelete={() => {
+                          if (
+                            deleteSeq.includes(filteredGroupList[newIndex].name)
+                          ) {
+                            setDeleteSeq(
+                              deleteSeq.filter(
+                                (v) => v !== filteredGroupList[newIndex].name
+                              )
+                            );
+                          } else {
+                            setDeleteSeq((prev) => [
+                              ...prev,
+                              filteredGroupList[newIndex].name,
+                            ]);
+                          }
+                        }}
+                      />
+                    );
+                  } else {
+                    return (
+                      <DndContext
+                        sensors={sensors}
+                        collisionDetection={closestCenter}
+                        onDragEnd={onAppendDragEnd}
+                      >
+                        <SortableContext
+                          items={appendSeq.map((x) => {
+                            return x.name;
+                          })}
+                        >
+                          {appendSeq.map((item, index) => {
+                            return (
+                              <GroupItem
+                                key={`${item.name}-${index}`}
+                                type="append"
+                                group={item}
+                                onDelete={() => {
+                                  setAppendSeq(
+                                    appendSeq.filter(
+                                      (v) => v.name !== item.name
+                                    )
+                                  );
+                                }}
+                              />
+                            );
+                          })}
+                        </SortableContext>
+                      </DndContext>
+                    );
+                  }
+                }}
+              />
+            </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: true, // 连字符
+              smoothScrolling: true, // 平滑滚动
+            }}
+            onChange={(value) => setCurrData(value)}
+          />
+        )}
+      </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",
+}));

+ 7 - 7
src/components/profile/profile-item.tsx

@@ -24,6 +24,7 @@ import {
   saveProfileFile,
 } from "@/services/cmds";
 import { Notice } from "@/components/base";
+import { GroupsEditorViewer } from "@/components/profile/groups-editor-viewer";
 import { RulesEditorViewer } from "@/components/profile/rules-editor-viewer";
 import { EditorViewer } from "@/components/profile/editor-viewer";
 import { ProfileBox } from "./profile-box";
@@ -501,14 +502,13 @@ export const ProfileItem = (props: Props) => {
         }}
         onClose={() => setProxiesOpen(false)}
       />
-      <EditorViewer
+      <GroupsEditorViewer
+        mergeUid={option?.merge ?? ""}
+        proxiesUid={option?.proxies ?? ""}
+        profileUid={uid}
+        property={option?.groups ?? ""}
         open={groupsOpen}
-        initialData={readProfileFile(option?.groups ?? "")}
-        language="yaml"
-        onSave={async (prev, curr) => {
-          await saveProfileFile(option?.groups ?? "", curr ?? "");
-          onSave && onSave(prev, curr);
-        }}
+        onSave={onSave}
         onClose={() => setGroupsOpen(false)}
       />
       <EditorViewer

+ 6 - 4
src/components/profile/rules-editor-viewer.tsx

@@ -335,15 +335,16 @@ export const RulesEditorViewer = (props: Props) => {
     let moreAppendGroups = moreGroupsObj?.["append"] || [];
     let moreDeleteGroups =
       moreGroupsObj?.["delete"] || ([] as string[] | { name: string }[]);
-    let groups = originGroups
-      .filter((group: any) => {
+    let groups = morePrependGroups.concat(
+      originGroups.filter((group: any) => {
         if (group.name) {
           return !moreDeleteGroups.includes(group.name);
         } else {
           return !moreDeleteGroups.includes(group);
         }
-      })
-      .concat(morePrependGroups, moreAppendGroups);
+      }),
+      moreAppendGroups
+    );
 
     let originRuleSetObj = yaml.load(data) as { "rule-providers": {} } | null;
     let originRuleSet = originRuleSetObj?.["rule-providers"] || {};
@@ -375,6 +376,7 @@ export const RulesEditorViewer = (props: Props) => {
   };
 
   useEffect(() => {
+    if (!open) return;
     fetchContent();
     fetchProfile();
   }, [open]);

+ 21 - 1
src/locales/en.json

@@ -61,7 +61,8 @@
   "No Resolve": "No Resolve",
   "Prepend Rule": "Prepend Rule",
   "Append Rule": "Append Rule",
-  "Delete Rule": "Delete Rule",
+  "Prepend Group": "Prepend Group",
+  "Append Group": "Append Group",
   "Rule Condition Required": "Rule Condition Required",
   "Invalid Rule": "Invalid Rule",
   "Advanced": "Advanced",
@@ -104,6 +105,25 @@
   "REJECT-DROP": "Discards requests",
   "PASS": "Skips this rule when matched",
   "Edit Groups": "Edit Proxy Groups",
+  "Group Type": "Group Type",
+  "Group Name": "Group Name",
+  "Use Proxies": "Use Proxies",
+  "Use Provider": "Use Provider",
+  "Health Check Url": "Health Check Url",
+  "Interval": "Interval",
+  "Lazy": "Lazy",
+  "Timeout": "Timeout",
+  "Max Failed Times": "Max Failed Times",
+  "Interface Name": "Interface Name",
+  "Routing Mark": "Routing Mark",
+  "Include All": "Include All Proxies and Providers",
+  "Include All Providers": "Include All Providers",
+  "Include All Proxies": "Include All Proxies",
+  "Exclude Filter": "Exclude Filter",
+  "Exclude Type": "Exclude Type",
+  "Expected Status": "Expected Status",
+  "Disable UDP": "Disable UDP",
+  "Hidden": "Hidden",
   "Extend Config": "Extend Config",
   "Extend Script": "Extend Script",
   "Global Merge": "Global Extend Config",

+ 21 - 1
src/locales/fa.json

@@ -61,7 +61,8 @@
   "No Resolve": "بدون حل",
   "Prepend Rule": "اضافه کردن قانون به ابتدا",
   "Append Rule": "اضافه کردن قانون به انتها",
-  "Delete Rule": "حذف قانون",
+  "Prepend Group": "اضافه کردن گروه به ابتدا",
+  "Append Group": "اضافه کردن گروه به انتها",
   "Rule Condition Required": "شرط قانون الزامی است",
   "Invalid Rule": "قانون نامعتبر",
   "DOMAIN": "مطابقت با نام کامل دامنه",
@@ -102,6 +103,25 @@
   "REJECT-DROP": "درخواست‌ها را نادیده می‌گیرد",
   "PASS": "این قانون را در صورت تطابق نادیده می‌گیرد",
   "Edit Groups": "ویرایش گروه‌های پروکسی",
+  "Group Type": "نوع گروه",
+  "Group Name": "نام گروه",
+  "Use Proxies": "استفاده از پروکسی‌ها",
+  "Use Provider": "استفاده از ارائه‌دهنده",
+  "Health Check Url": "آدرس بررسی سلامت",
+  "Interval": "فاصله زمانی",
+  "Lazy": "تنبل",
+  "Timeout": "زمان قطع",
+  "Max Failed Times": "حداکثر تعداد شکست‌ها",
+  "Interface Name": "نام رابط",
+  "Routing Mark": "علامت مسیریابی",
+  "Include All": "شامل همه پروکسی‌ها و ارائه‌دهنده‌ها",
+  "Include All Providers": "شامل همه ارائه‌دهنده‌ها",
+  "Include All Proxies": "شامل همه پروکسی‌ها",
+  "Exclude Filter": "فیلتر استثناء",
+  "Exclude Type": "نوع استثناء",
+  "Expected Status": "وضعیت مورد انتظار",
+  "Disable UDP": "غیرفعال کردن UDP",
+  "Hidden": "مخفی",
   "Extend Config": "توسعه پیکربندی",
   "Extend Script": "ادغام اسکریپت",
   "Global Merge": "تنظیمات گسترده‌ی سراسری",

+ 21 - 1
src/locales/ru.json

@@ -61,7 +61,8 @@
   "No Resolve": "Без разрешения",
   "Prepend Rule": "Добавить правило в начало",
   "Append Rule": "Добавить правило в конец",
-  "Delete Rule": "Удалить правило",
+  "Prepend Group": "Добавить группу в начало",
+  "Append Group": "Добавить группу в конец",
   "Rule Condition Required": "Требуется условие правила",
   "Invalid Rule": "Недействительное правило",
   "DOMAIN": "Соответствует полному доменному имени",
@@ -102,6 +103,25 @@
   "REJECT-DROP": "Отклоняет запросы",
   "PASS": "Пропускает это правило при совпадении",
   "Edit Groups": "Редактировать группы прокси",
+  "Group Type": "Тип группы",
+  "Group Name": "Имя группы",
+  "Use Proxies": "Использовать прокси",
+  "Use Provider": "Использовать провайдера",
+  "Health Check Url": "URL проверки здоровья",
+  "Interval": "Интервал",
+  "Lazy": "Ленивый",
+  "Timeout": "Таймаут",
+  "Max Failed Times": "Максимальное количество неудач",
+  "Interface Name": "Имя интерфейса",
+  "Routing Mark": "Марка маршрутизации",
+  "Include All": "Включить все прокси и провайдеры",
+  "Include All Providers": "Включить всех провайдеров",
+  "Include All Proxies": "Включить все прокси",
+  "Exclude Filter": "Исключить фильтр",
+  "Exclude Type": "Тип исключения",
+  "Expected Status": "Ожидаемый статус",
+  "Disable UDP": "Отключить UDP",
+  "Hidden": "Скрытый",
   "Extend Config": "Изменить Merge.",
   "Extend Script": "Изменить Script",
   "Global Merge": "Глобальный расширенный Настройки",

+ 21 - 1
src/locales/zh.json

@@ -61,7 +61,8 @@
   "No Resolve": "跳过DNS解析",
   "Prepend Rule": "添加前置规则",
   "Append Rule": "添加后置规则",
-  "Delete Rule": "删除规则",
+  "Prepend Group": "添加前置代理组",
+  "Append Group": "添加后置代理组",
   "Rule Condition Required": "规则条件缺失",
   "Invalid Rule": "无效规则",
   "Advanced": "高级",
@@ -104,6 +105,25 @@
   "REJECT-DROP": "抛弃请求",
   "PASS": "跳过此规则",
   "Edit Groups": "编辑代理组",
+  "Group Type": "代理组类型",
+  "Group Name": "代理组组名",
+  "Use Proxies": "引入代理",
+  "Use Provider": "引入代理集合",
+  "Health Check Url": "健康检查测试地址",
+  "Interval": "检查间隔",
+  "Lazy": "懒惰状态",
+  "Timeout": "超时时间",
+  "Max Failed Times": "最大失败次数",
+  "Interface Name": "出站接口",
+  "Routing Mark": "路由标记",
+  "Include All": "引入所有出站代理以及代理集合",
+  "Include All Providers": "引入所有代理集合",
+  "Include All Proxies": "引入所有出站代理",
+  "Exclude Filter": "排除节点",
+  "Exclude Type": "排除节点类型",
+  "Expected Status": "期望状态码",
+  "Disable UDP": "禁用UDP",
+  "Hidden": "隐藏该组",
   "Extend Config": "扩展配置",
   "Extend Script": "扩展脚本",
   "Global Merge": "全局扩展配置",

+ 1 - 0
src/services/types.d.ts

@@ -213,6 +213,7 @@ interface IProxyGroupConfig {
   interval?: number;
   lazy?: boolean;
   timeout?: number;
+  "max-failed-times"?: number;
   "disable-udp"?: boolean;
   "interface-name": string;
   "routing-mark"?: number;