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 ( + + + { + + {t("Edit Groups")} + + + + + } + + + + {visible ? ( + <> + + + ( + + + value && field.onChange(value)} + renderInput={(params) => } + /> + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + value && field.onChange(value)} + renderInput={(params) => } + /> + + )} + /> + ( + + + value && field.onChange(value)} + renderInput={(params) => } + /> + + )} + /> + + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + ( + + + + + )} + /> + + + + + + + + + + + setMatch(() => match)} + /> + 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 ( + + { + return x.name; + })} + > + {prependSeq.map((item, index) => { + return ( + { + setPrependSeq( + prependSeq.filter( + (v) => v.name !== item.name + ) + ); + }} + /> + ); + })} + + + ); + } else if (index < filteredGroupList.length + shift) { + let newIndex = index - shift; + return ( + { + if ( + deleteSeq.includes(filteredGroupList[newIndex].name) + ) { + setDeleteSeq( + deleteSeq.filter( + (v) => v !== filteredGroupList[newIndex].name + ) + ); + } else { + setDeleteSeq((prev) => [ + ...prev, + filteredGroupList[newIndex].name, + ]); + } + }} + /> + ); + } else { + return ( + + { + return x.name; + })} + > + {appendSeq.map((item, index) => { + return ( + { + setAppendSeq( + appendSeq.filter( + (v) => v.name !== item.name + ) + ); + }} + /> + ); + })} + + + ); + } + }} + /> + + + ) : ( + = 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)} + /> + )} + + + + + + + + + ); +}; + +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;