diff --git a/src/components/profile/group-item.tsx b/src/components/profile/group-item.tsx
new file mode 100644
index 00000000..3da2fc58
--- /dev/null
+++ b/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 (
+ ({
+ 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") && (
+
+ )}
+ {group.icon && group.icon?.trim().startsWith("data") && (
+
+ )}
+ {group.icon && group.icon?.trim().startsWith("
+ )}
+
+ {group.name}
+
+ }
+ secondary={
+
+
+ {group.type}
+
+
+ }
+ secondaryTypographyProps={{
+ sx: {
+ display: "flex",
+ alignItems: "center",
+ color: "#ccc",
+ },
+ }}
+ />
+
+ {type === "delete" ? : }
+
+
+ );
+};
+
+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",
+}));
diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx
new file mode 100644
index 00000000..7f0d56ac
--- /dev/null
+++ b/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({
+ defaultValues: {
+ type: "select",
+ name: "",
+ lazy: true,
+ },
+ });
+ const [groupList, setGroupList] = useState([]);
+ const [proxyPolicyList, setProxyPolicyList] = useState([]);
+ const [proxyProviderList, setProxyProviderList] = useState([]);
+ const [prependSeq, setPrependSeq] = useState([]);
+ const [appendSeq, setAppendSeq] = useState([]);
+ const [deleteSeq, setDeleteSeq] = useState([]);
+
+ 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 (
+
+ );
+};
+
+const Item = styled(ListItem)(() => ({
+ padding: "5px 2px",
+}));
diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx
index 88243adf..8821932f 100644
--- a/src/components/profile/profile-item.tsx
+++ b/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)}
/>
- {
- await saveProfileFile(option?.groups ?? "", curr ?? "");
- onSave && onSave(prev, curr);
- }}
+ onSave={onSave}
onClose={() => setGroupsOpen(false)}
/>
{
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]);
diff --git a/src/locales/en.json b/src/locales/en.json
index 19aa9136..94cafc25 100644
--- a/src/locales/en.json
+++ b/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",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index b5a80a03..aba07ebd 100644
--- a/src/locales/fa.json
+++ b/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": "تنظیمات گستردهی سراسری",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 72ab09ad..e105f27a 100644
--- a/src/locales/ru.json
+++ b/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": "Глобальный расширенный Настройки",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 138a9e67..3323a2a6 100644
--- a/src/locales/zh.json
+++ b/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": "全局扩展配置",
diff --git a/src/services/types.d.ts b/src/services/types.d.ts
index 70d50498..777a63b4 100644
--- a/src/services/types.d.ts
+++ b/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;