refactor: profile components

This commit is contained in:
Slinetrac
2025-10-14 23:45:12 +08:00
Unverified
parent 5d114806f7
commit 4f2633a62b
11 changed files with 382 additions and 272 deletions

View File

@@ -20,7 +20,7 @@ import metaSchema from "meta-json-schema/schemas/meta-json-schema.json";
import * as monaco from "monaco-editor";
import { configureMonacoYaml } from "monaco-yaml";
import { nanoid } from "nanoid";
import { ReactNode, useEffect, useRef, useState } from "react";
import { ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import MonacoEditor from "react-monaco-editor";
import pac from "types-pac/pac.d.ts?raw";
@@ -63,13 +63,13 @@ const monacoInitialization = () => {
{
uri: "http://example.com/meta-json-schema.json",
fileMatch: ["**/*.clash.yaml"],
// @ts-ignore
// @ts-expect-error -- meta schema JSON import does not satisfy JSONSchema7 at compile time
schema: metaSchema as JSONSchema7,
},
{
uri: "http://example.com/clash-verge-merge-json-schema.json",
fileMatch: ["**/*.merge.yaml"],
// @ts-ignore
// @ts-expect-error -- merge schema JSON import does not satisfy JSONSchema7 at compile time
schema: mergeSchema as JSONSchema7,
},
],
@@ -87,8 +87,8 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
const {
open = false,
title = t("Edit File"),
initialData = Promise.resolve(""),
title,
initialData,
readOnly = false,
language = "yaml",
schema,
@@ -97,6 +97,12 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
onClose,
} = props;
const resolvedTitle = title ?? t("Edit File");
const resolvedInitialData = useMemo(
() => initialData ?? Promise.resolve(""),
[initialData],
);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>(undefined);
const prevData = useRef<string | undefined>("");
const currData = useRef<string | undefined>("");
@@ -111,7 +117,7 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
editorRef.current = editor;
// retrieve initial data
await initialData.then((data) => {
await resolvedInitialData.then((data) => {
prevData.current = data;
currData.current = data;
@@ -133,7 +139,9 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
const handleSave = useLockFn(async () => {
try {
!readOnly && onSave?.(prevData.current, currData.current);
if (!readOnly) {
onSave?.(prevData.current, currData.current);
}
onClose();
} catch (err: any) {
showNotice("error", err.message || err.toString());
@@ -148,10 +156,14 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
}
});
const editorResize = debounce(() => {
editorRef.current?.layout();
setTimeout(() => editorRef.current?.layout(), 500);
}, 100);
const editorResize = useMemo(
() =>
debounce(() => {
editorRef.current?.layout();
setTimeout(() => editorRef.current?.layout(), 500);
}, 100),
[],
);
useEffect(() => {
const onResized = debounce(() => {
@@ -167,11 +179,11 @@ export const EditorViewer = <T extends Language>(props: Props<T>) => {
editorRef.current?.dispose();
editorRef.current = undefined;
};
}, []);
}, [editorResize]);
return (
<Dialog open={open} onClose={onClose} maxWidth="xl" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogTitle>{resolvedTitle}</DialogTitle>
<DialogContent
sx={{

View File

@@ -24,37 +24,50 @@ export const GroupItem = (props: Props) => {
const sortable = type === "prepend" || type === "append";
const {
attributes,
listeners,
setNodeRef,
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: sortableSetNodeRef,
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: group.name })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
} = useSortable({
id: group.name,
disabled: !sortable,
});
const dragAttributes = sortable ? sortableAttributes : undefined;
const dragListeners = sortable ? sortableListeners : undefined;
const dragNodeRef = sortable ? sortableSetNodeRef : undefined;
const [iconCachePath, setIconCachePath] = useState("");
useEffect(() => {
initIconCachePath();
}, [group]);
let cancelled = false;
const initIconCachePath = async () => {
const icon = group.icon?.trim() ?? "";
if (icon.startsWith("http")) {
try {
const fileName =
group.name.replaceAll(" ", "") + "-" + getFileName(icon);
const iconPath = await downloadIconCache(icon, fileName);
if (!cancelled) {
setIconCachePath(convertFileSrc(iconPath));
}
} catch {
if (!cancelled) {
setIconCachePath("");
}
}
} else if (!cancelled) {
setIconCachePath("");
}
};
async function initIconCachePath() {
if (group.icon && group.icon.trim().startsWith("http")) {
const fileName =
group.name.replaceAll(" ", "") + "-" + getFileName(group.icon);
const iconPath = await downloadIconCache(group.icon, fileName);
setIconCachePath(convertFileSrc(iconPath));
}
}
void initIconCachePath();
return () => {
cancelled = true;
};
}, [group.icon, group.name]);
function getFileName(url: string) {
return url.substring(url.lastIndexOf("/") + 1);
@@ -108,9 +121,9 @@ export const GroupItem = (props: Props) => {
/>
)}
<ListItemText
{...attributes}
{...listeners}
ref={setNodeRef}
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
@@ -133,11 +146,13 @@ export const GroupItem = (props: Props) => {
</Box>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
},
}}
/>

View File

@@ -36,7 +36,13 @@ import {
cancelIdleCallback,
} from "foxact/request-idle-callback";
import yaml from "js-yaml";
import { useEffect, useMemo, useState } from "react";
import {
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import MonacoEditor from "react-monaco-editor";
@@ -160,7 +166,7 @@ export const GroupsEditorViewer = (props: Props) => {
}
}
};
const fetchContent = async () => {
const fetchContent = useCallback(async () => {
const data = await readProfileFile(property);
const obj = yaml.load(data) as ISeqProfileConfig | null;
@@ -170,21 +176,20 @@ export const GroupsEditorViewer = (props: Props) => {
setPrevData(data);
setCurrData(data);
};
}, [property]);
useEffect(() => {
if (currData === "") return;
if (visualization !== true) return;
if (currData === "" || visualization !== true) {
return;
}
const obj = yaml.load(currData) as {
prepend: [];
append: [];
delete: [];
} | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
}, [visualization]);
const obj = yaml.load(currData) as ISeqProfileConfig | null;
startTransition(() => {
setPrependSeq(obj?.prepend ?? []);
setAppendSeq(obj?.append ?? []);
setDeleteSeq(obj?.delete ?? []);
});
}, [currData, visualization]);
// 优化异步处理大数据yaml.dump避免UI卡死
useEffect(() => {
@@ -210,7 +215,7 @@ export const GroupsEditorViewer = (props: Props) => {
}
}, [prependSeq, appendSeq, deleteSeq]);
const fetchProxyPolicy = async () => {
const fetchProxyPolicy = useCallback(async () => {
const data = await readProfileFile(profileUid);
const proxiesData = await readProfileFile(proxiesUid);
const originGroupsObj = yaml.load(data) as {
@@ -246,8 +251,8 @@ export const GroupsEditorViewer = (props: Props) => {
proxies.map((proxy: any) => proxy.name),
),
);
};
const fetchProfile = async () => {
}, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]);
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid);
const mergeData = await readProfileFile(mergeUid);
const globalMergeData = await readProfileFile("Merge");
@@ -257,17 +262,17 @@ export const GroupsEditorViewer = (props: Props) => {
} | null;
const originProviderObj = yaml.load(data) as {
"proxy-providers": {};
"proxy-providers": Record<string, unknown>;
} | null;
const originProvider = originProviderObj?.["proxy-providers"] || {};
const moreProviderObj = yaml.load(mergeData) as {
"proxy-providers": {};
"proxy-providers": Record<string, unknown>;
} | null;
const moreProvider = moreProviderObj?.["proxy-providers"] || {};
const globalProviderObj = yaml.load(globalMergeData) as {
"proxy-providers": {};
"proxy-providers": Record<string, unknown>;
} | null;
const globalProvider = globalProviderObj?.["proxy-providers"] || {};
@@ -280,21 +285,27 @@ export const GroupsEditorViewer = (props: Props) => {
setProxyProviderList(Object.keys(provider));
setGroupList(originGroupsObj?.["proxy-groups"] || []);
};
const getInterfaceNameList = async () => {
}, [mergeUid, profileUid]);
const getInterfaceNameList = useCallback(async () => {
const list = await getNetworkInterfaces();
setInterfaceNameList(list);
};
}, []);
useEffect(() => {
fetchProxyPolicy();
}, [prependSeq, appendSeq, deleteSeq]);
}, [fetchProxyPolicy]);
useEffect(() => {
if (!open) return;
fetchContent();
fetchProxyPolicy();
fetchProfile();
getInterfaceNameList();
}, [open]);
}, [
fetchContent,
fetchProfile,
fetchProxyPolicy,
getInterfaceNameList,
open,
]);
const validateGroup = () => {
const group = formIns.getValues();
@@ -811,10 +822,10 @@ export const GroupsEditorViewer = (props: Props) => {
return x.name;
})}
>
{filteredPrependSeq.map((item, index) => {
{filteredPrependSeq.map((item) => {
return (
<GroupItem
key={`${item.name}-${index}`}
key={item.name}
type="prepend"
group={item}
onDelete={() => {
@@ -834,7 +845,7 @@ export const GroupsEditorViewer = (props: Props) => {
const newIndex = index - shift;
return (
<GroupItem
key={`${filteredGroupList[newIndex].name}-${index}`}
key={filteredGroupList[newIndex].name}
type={
deleteSeq.includes(filteredGroupList[newIndex].name)
? "delete"
@@ -871,10 +882,10 @@ export const GroupsEditorViewer = (props: Props) => {
return x.name;
})}
>
{filteredAppendSeq.map((item, index) => {
{filteredAppendSeq.map((item) => {
return (
<GroupItem
key={`${item.name}-${index}`}
key={item.name}
type="append"
group={item}
onDelete={() => {

View File

@@ -37,8 +37,8 @@ export const LogViewer = (props: Props) => {
pb: 1,
}}
>
{logInfo.map(([level, log], index) => (
<Fragment key={index.toString()}>
{logInfo.map(([level, log]) => (
<Fragment key={`${level}-${log}`}>
<Typography color="text.secondary" component="div">
<Chip
label={level}

View File

@@ -19,7 +19,7 @@ import {
import { open } from "@tauri-apps/plugin-shell";
import { useLockFn } from "ahooks";
import dayjs from "dayjs";
import { useEffect, useState } from "react";
import { useEffect, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { mutate } from "swr";
@@ -61,6 +61,7 @@ interface Props {
export const ProfileItem = (props: Props) => {
const {
id,
selected,
activating,
itemData,
@@ -80,11 +81,11 @@ export const ProfileItem = (props: Props) => {
transition,
isDragging,
} = useSortable({
id: props.id,
id,
});
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const loadingCache = useLoadingCache();
const setLoadingCache = useSetLoadingCache();
@@ -166,37 +167,44 @@ export const ProfileItem = (props: Props) => {
if (showNextUpdate) {
fetchNextUpdateTime();
}
}, [showNextUpdate, itemData.option?.update_interval, updated]);
}, [
fetchNextUpdateTime,
showNextUpdate,
itemData.option?.update_interval,
updated,
]);
// 订阅定时器更新事件
useEffect(() => {
let refreshTimeout: ReturnType<typeof setTimeout> | undefined;
// 处理定时器更新事件 - 这个事件专门用于通知定时器变更
const handleTimerUpdate = (event: any) => {
const updatedUid = event.payload as string;
const handleTimerUpdate = (event: Event) => {
const source = event as CustomEvent<string> & { payload?: string };
const updatedUid = source.detail ?? source.payload;
// 只有当更新的是当前配置时才刷新显示
if (updatedUid === itemData.uid && showNextUpdate) {
console.log(`收到定时器更新事件: uid=${updatedUid}`);
setTimeout(() => {
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
refreshTimeout = window.setTimeout(() => {
fetchNextUpdateTime(true);
}, 1000);
}
};
// 只注册定时器更新事件监听
window.addEventListener(
"verge://timer-updated",
handleTimerUpdate as EventListener,
);
window.addEventListener("verge://timer-updated", handleTimerUpdate);
return () => {
if (refreshTimeout) {
clearTimeout(refreshTimeout);
}
// 清理事件监听
window.removeEventListener(
"verge://timer-updated",
handleTimerUpdate as EventListener,
);
window.removeEventListener("verge://timer-updated", handleTimerUpdate);
};
}, [showNextUpdate, itemData.uid]);
}, [fetchNextUpdateTime, itemData.uid, showNextUpdate]);
// local file mode
// remote file mode
@@ -217,11 +225,11 @@ export const ProfileItem = (props: Props) => {
const loading = loadingCache[itemData.uid] ?? false;
// interval update fromNow field
const [, setRefresh] = useState({});
const [, forceRefresh] = useReducer((value: number) => value + 1, 0);
useEffect(() => {
if (!hasUrl) return;
let timer: any = null;
let timer: ReturnType<typeof setTimeout> | undefined;
const handler = () => {
const now = Date.now();
@@ -232,7 +240,7 @@ export const ProfileItem = (props: Props) => {
const wait = now - lastUpdate >= 36e5 ? 30e5 : 5e4;
timer = setTimeout(() => {
setRefresh({});
forceRefresh();
handler();
}, wait);
};
@@ -240,9 +248,12 @@ export const ProfileItem = (props: Props) => {
handler();
return () => {
if (timer) clearTimeout(timer);
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
}, [hasUrl, updated]);
}, [forceRefresh, hasUrl, updated]);
const [fileOpen, setFileOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
@@ -382,7 +393,9 @@ export const ProfileItem = (props: Props) => {
setAnchorEl(null);
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
onSelectionChange && onSelectionChange();
if (onSelectionChange) {
onSelectionChange();
}
} else {
setConfirmOpen(true);
}
@@ -426,7 +439,9 @@ export const ProfileItem = (props: Props) => {
setAnchorEl(null);
if (batchMode) {
// If in batch mode, just toggle selection instead of showing delete confirmation
onSelectionChange && onSelectionChange();
if (onSelectionChange) {
onSelectionChange();
}
} else {
setConfirmOpen(true);
}
@@ -444,14 +459,16 @@ export const ProfileItem = (props: Props) => {
// 监听自动更新事件
useEffect(() => {
const handleUpdateStarted = (event: CustomEvent) => {
if (event.detail.uid === itemData.uid) {
const handleUpdateStarted = (event: Event) => {
const customEvent = event as CustomEvent<{ uid?: string }>;
if (customEvent.detail?.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true }));
}
};
const handleUpdateCompleted = (event: CustomEvent) => {
if (event.detail.uid === itemData.uid) {
const handleUpdateCompleted = (event: Event) => {
const customEvent = event as CustomEvent<{ uid?: string }>;
if (customEvent.detail?.uid === itemData.uid) {
setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false }));
// 更新完成后刷新显示
if (showNextUpdate) {
@@ -461,27 +478,18 @@ export const ProfileItem = (props: Props) => {
};
// 注册事件监听
window.addEventListener(
"profile-update-started",
handleUpdateStarted as EventListener,
);
window.addEventListener(
"profile-update-completed",
handleUpdateCompleted as EventListener,
);
window.addEventListener("profile-update-started", handleUpdateStarted);
window.addEventListener("profile-update-completed", handleUpdateCompleted);
return () => {
// 清理事件监听
window.removeEventListener(
"profile-update-started",
handleUpdateStarted as EventListener,
);
window.removeEventListener("profile-update-started", handleUpdateStarted);
window.removeEventListener(
"profile-update-completed",
handleUpdateCompleted as EventListener,
handleUpdateCompleted,
);
};
}, [itemData.uid, showNextUpdate]);
}, [fetchNextUpdateTime, itemData.uid, setLoadingCache, showNextUpdate]);
return (
<Box
@@ -506,7 +514,7 @@ export const ProfileItem = (props: Props) => {
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
setAnchorEl(event.currentTarget as HTMLElement);
event.preventDefault();
}}
>
@@ -543,7 +551,9 @@ export const ProfileItem = (props: Props) => {
sx={{ padding: "2px", marginRight: "4px", marginLeft: "-8px" }}
onClick={(e) => {
e.stopPropagation();
onSelectionChange && onSelectionChange();
if (onSelectionChange) {
onSelectionChange();
}
}}
>
{isSelected ? (
@@ -737,7 +747,7 @@ export const ProfileItem = (props: Props) => {
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(uid, curr ?? "");
onSave && onSave(prev, curr);
onSave?.(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
@@ -783,7 +793,7 @@ export const ProfileItem = (props: Props) => {
schema="clash"
onSave={async (prev, curr) => {
await saveProfileFile(option?.merge ?? "", curr ?? "");
onSave && onSave(prev, curr);
onSave?.(prev, curr);
}}
onClose={() => setMergeOpen(false)}
/>
@@ -795,7 +805,7 @@ export const ProfileItem = (props: Props) => {
language="javascript"
onSave={async (prev, curr) => {
await saveProfileFile(option?.script ?? "", curr ?? "");
onSave && onSave(prev, curr);
onSave?.(prev, curr);
}}
onClose={() => setScriptOpen(false)}
/>

View File

@@ -25,12 +25,15 @@ interface Props {
onSave?: (prev?: string, curr?: string) => void;
}
const EMPTY_LOG_INFO: [string, string][] = [];
// profile enhanced item
export const ProfileMore = (props: Props) => {
const { id, logInfo = [], onSave } = props;
const { id, logInfo, onSave } = props;
const entries = logInfo ?? EMPTY_LOG_INFO;
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = useState<any>(null);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [position, setPosition] = useState({ left: 0, top: 0 });
const [fileOpen, setFileOpen] = useState(false);
const [logOpen, setLogOpen] = useState(false);
@@ -49,7 +52,7 @@ export const ProfileMore = (props: Props) => {
}
});
const hasError = !!logInfo.find((e) => e[0] === "exception");
const hasError = entries.some(([level]) => level === "exception");
const itemMenu = [
{ label: "Edit File", handler: onEditFile },
@@ -71,7 +74,7 @@ export const ProfileMore = (props: Props) => {
onContextMenu={(event) => {
const { clientX, clientY } = event;
setPosition({ top: clientY, left: clientX });
setAnchorEl(event.currentTarget);
setAnchorEl(event.currentTarget as HTMLElement);
event.preventDefault();
}}
>
@@ -173,7 +176,7 @@ export const ProfileMore = (props: Props) => {
schema={id === "Merge" ? "clash" : undefined}
onSave={async (prev, curr) => {
await saveProfileFile(id, curr ?? "");
onSave && onSave(prev, curr);
onSave?.(prev, curr);
}}
onClose={() => setFileOpen(false)}
/>
@@ -181,7 +184,7 @@ export const ProfileMore = (props: Props) => {
{logOpen && (
<LogViewer
open={logOpen}
logInfo={logInfo}
logInfo={entries}
onClose={() => setLogOpen(false)}
/>
)}

View File

@@ -45,23 +45,19 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
// file input
const fileDataRef = useRef<string | null>(null);
const {
control,
watch,
register: _register,
...formIns
} = useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
const { control, watch, setValue, reset, handleSubmit, getValues } =
useForm<IProfileItem>({
defaultValues: {
type: "remote",
name: "",
desc: "",
url: "",
option: {
with_proxy: false,
self_proxy: false,
},
},
},
});
});
useImperativeHandle(ref, () => ({
create: () => {
@@ -71,7 +67,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
edit: (item: IProfileItem) => {
if (item) {
Object.entries(item).forEach(([key, value]) => {
formIns.setValue(key as any, value);
setValue(key as any, value);
});
}
setOpenType("edit");
@@ -83,15 +79,15 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
const withProxy = watch("option.with_proxy");
useEffect(() => {
if (selfProxy) formIns.setValue("option.with_proxy", false);
}, [selfProxy]);
if (selfProxy) setValue("option.with_proxy", false);
}, [selfProxy, setValue]);
useEffect(() => {
if (withProxy) formIns.setValue("option.self_proxy", false);
}, [withProxy]);
if (withProxy) setValue("option.self_proxy", false);
}, [setValue, withProxy]);
const handleOk = useLockFn(
formIns.handleSubmit(async (form) => {
handleSubmit(async (form) => {
if (form.option?.timeout_seconds) {
form.option.timeout_seconds = +form.option.timeout_seconds;
}
@@ -183,7 +179,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
// 成功后的操作
setOpen(false);
setTimeout(() => formIns.reset(), 500);
setTimeout(() => reset(), 500);
fileDataRef.current = null;
// 优化UI先关闭异步通知父组件
@@ -202,7 +198,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
try {
setOpen(false);
fileDataRef.current = null;
setTimeout(() => formIns.reset(), 500);
setTimeout(() => reset(), 500);
} catch (e) {
console.warn("[ProfileViewer] handleClose error:", e);
}
@@ -341,7 +337,7 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) {
{isLocal && openType === "new" && (
<FileInput
onChange={(file, val) => {
formIns.setValue("name", formIns.getValues("name") || file.name);
setValue("name", getValues("name") || file.name);
fileDataRef.current = val;
}}
/>

View File

@@ -29,7 +29,13 @@ import {
} from "@mui/material";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
import { useEffect, useMemo, useState } from "react";
import {
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import MonacoEditor from "react-monaco-editor";
import { Virtuoso } from "react-virtuoso";
@@ -145,7 +151,9 @@ export const ProxiesEditorViewer = (props: Props) => {
const lines = uris.trim().split("\n");
let idx = 0;
const batchSize = 50;
function parseBatch() {
let parseTimer: ReturnType<typeof setTimeout> | undefined;
const parseBatch = () => {
const end = Math.min(idx + batchSize, lines.length);
for (; idx < end; idx++) {
const uri = lines[idx];
@@ -165,14 +173,18 @@ export const ProxiesEditorViewer = (props: Props) => {
}
}
if (idx < lines.length) {
setTimeout(parseBatch, 0);
parseTimer = window.setTimeout(parseBatch, 0);
} else {
if (parseTimer) {
clearTimeout(parseTimer);
parseTimer = undefined;
}
cb(proxies);
}
}
};
parseBatch();
};
const fetchProfile = async () => {
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid);
const originProxiesObj = yaml.load(data) as {
@@ -180,9 +192,9 @@ export const ProxiesEditorViewer = (props: Props) => {
} | null;
setProxyList(originProxiesObj?.proxies || []);
};
}, [profileUid]);
const fetchContent = async () => {
const fetchContent = useCallback(async () => {
const data = await readProfileFile(property);
const obj = yaml.load(data) as ISeqProfileConfig | null;
@@ -192,50 +204,61 @@ export const ProxiesEditorViewer = (props: Props) => {
setPrevData(data);
setCurrData(data);
};
}, [property]);
useEffect(() => {
if (currData === "") return;
if (visualization !== true) return;
const obj = yaml.load(currData) as {
prepend: [];
append: [];
delete: [];
} | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
}, [visualization]);
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
} catch (e) {
console.warn("[ProxiesEditorViewer] yaml.dump failed:", e);
// 防止异常导致UI卡死
}
};
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize);
} else {
setTimeout(serialize, 0);
}
if (currData === "" || visualization !== true) {
return;
}
const obj = yaml.load(currData) as ISeqProfileConfig | null;
startTransition(() => {
setPrependSeq(obj?.prepend ?? []);
setAppendSeq(obj?.append ?? []);
setDeleteSeq(obj?.delete ?? []);
});
}, [currData, visualization]);
useEffect(() => {
if (!(prependSeq && appendSeq && deleteSeq)) {
return;
}
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
} catch (e) {
console.warn("[ProxiesEditorViewer] yaml.dump failed:", e);
// 防止异常导致UI卡死
}
};
let idleId: number | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(serialize);
} else {
timeoutId = window.setTimeout(serialize, 0);
}
return () => {
if (idleId !== undefined && window.cancelIdleCallback) {
window.cancelIdleCallback(idleId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [prependSeq, appendSeq, deleteSeq]);
useEffect(() => {
if (!open) return;
fetchContent();
fetchProfile();
}, [open]);
}, [fetchContent, fetchProfile, open]);
const handleSave = useLockFn(async () => {
try {
@@ -357,10 +380,10 @@ export const ProxiesEditorViewer = (props: Props) => {
return x.name;
})}
>
{filteredPrependSeq.map((item, index) => {
{filteredPrependSeq.map((item) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
key={item.name}
type="prepend"
proxy={item}
onDelete={() => {
@@ -380,7 +403,7 @@ export const ProxiesEditorViewer = (props: Props) => {
const newIndex = index - shift;
return (
<ProxyItem
key={`${filteredProxyList[newIndex].name}-${index}`}
key={filteredProxyList[newIndex].name}
type={
deleteSeq.includes(filteredProxyList[newIndex].name)
? "delete"
@@ -417,10 +440,10 @@ export const ProxiesEditorViewer = (props: Props) => {
return x.name;
})}
>
{filteredAppendSeq.map((item, index) => {
{filteredAppendSeq.map((item) => {
return (
<ProxyItem
key={`${item.name}-${index}`}
key={item.name}
type="append"
proxy={item}
onDelete={() => {

View File

@@ -21,22 +21,19 @@ export const ProxyItem = (props: Props) => {
const sortable = type === "prepend" || type === "append";
const {
attributes,
listeners,
setNodeRef,
attributes: sortableAttributes,
listeners: sortableListeners,
setNodeRef: sortableSetNodeRef,
transform,
transition,
isDragging,
} = sortable
? useSortable({ id: proxy.name })
: {
attributes: {},
listeners: {},
setNodeRef: null,
transform: null,
transition: null,
isDragging: false,
};
} = useSortable({
id: proxy.name,
disabled: !sortable,
});
const dragAttributes = sortable ? sortableAttributes : undefined;
const dragListeners = sortable ? sortableListeners : undefined;
const dragNodeRef = sortable ? sortableSetNodeRef : undefined;
return (
<ListItem
@@ -60,9 +57,9 @@ export const ProxyItem = (props: Props) => {
})}
>
<ListItemText
{...attributes}
{...listeners}
ref={setNodeRef}
{...(dragAttributes ?? {})}
{...(dragListeners ?? {})}
ref={dragNodeRef}
sx={{ cursor: sortable ? "move" : "" }}
primary={
<StyledPrimary
@@ -86,11 +83,13 @@ export const ProxyItem = (props: Props) => {
</Box>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
},
}}
/>

View File

@@ -95,11 +95,13 @@ export const RuleItem = (props: Props) => {
</StyledSubtitle>
</ListItemTextChild>
}
secondaryTypographyProps={{
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
slotProps={{
secondary: {
sx: {
display: "flex",
alignItems: "center",
color: "#ccc",
},
},
}}
/>

View File

@@ -31,7 +31,13 @@ import {
} from "@mui/material";
import { useLockFn } from "ahooks";
import yaml from "js-yaml";
import { useEffect, useMemo, useState } from "react";
import {
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import MonacoEditor from "react-monaco-editor";
import { Virtuoso } from "react-virtuoso";
@@ -305,7 +311,7 @@ export const RulesEditorViewer = (props: Props) => {
}
}
};
const fetchContent = async () => {
const fetchContent = useCallback(async () => {
const data = await readProfileFile(property);
const obj = yaml.load(data) as ISeqProfileConfig | null;
@@ -315,42 +321,57 @@ export const RulesEditorViewer = (props: Props) => {
setPrevData(data);
setCurrData(data);
};
}, [property]);
useEffect(() => {
if (currData === "") return;
if (visualization !== true) return;
if (currData === "" || visualization !== true) {
return;
}
const obj = yaml.load(currData) as ISeqProfileConfig | null;
setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []);
}, [visualization]);
startTransition(() => {
setPrependSeq(obj?.prepend ?? []);
setAppendSeq(obj?.append ?? []);
setDeleteSeq(obj?.delete ?? []);
});
}, [currData, visualization]);
// 优化异步处理大数据yaml.dump避免UI卡死
useEffect(() => {
if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
} catch (e: any) {
showNotice("error", e?.message || e?.toString() || "YAML dump error");
}
};
if (window.requestIdleCallback) {
window.requestIdleCallback(serialize);
} else {
setTimeout(serialize, 0);
}
if (!(prependSeq && appendSeq && deleteSeq)) {
return;
}
const serialize = () => {
try {
setCurrData(
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
} catch (e: any) {
showNotice("error", e?.message || e?.toString() || "YAML dump error");
}
};
let idleId: number | undefined;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (window.requestIdleCallback) {
idleId = window.requestIdleCallback(serialize);
} else {
timeoutId = window.setTimeout(serialize, 0);
}
return () => {
if (idleId !== undefined && window.cancelIdleCallback) {
window.cancelIdleCallback(idleId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [prependSeq, appendSeq, deleteSeq]);
const fetchProfile = async () => {
const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid); // 原配置文件
const groupsData = await readProfileFile(groupsUid); // groups配置文件
const mergeData = await readProfileFile(mergeUid); // merge配置文件
@@ -358,13 +379,25 @@ export const RulesEditorViewer = (props: Props) => {
const rulesObj = yaml.load(data) as { rules: [] } | null;
const originGroupsObj = yaml.load(data) as { "proxy-groups": [] } | null;
const originGroupsObj = yaml.load(data) as {
"proxy-groups": IProxyGroupConfig[];
} | null;
const originGroups = originGroupsObj?.["proxy-groups"] || [];
const moreGroupsObj = yaml.load(groupsData) as ISeqProfileConfig | null;
const morePrependGroups = moreGroupsObj?.["prepend"] || [];
const moreAppendGroups = moreGroupsObj?.["append"] || [];
const moreDeleteGroups =
moreGroupsObj?.["delete"] || ([] as string[] | { name: string }[]);
const rawPrependGroups = moreGroupsObj?.["prepend"];
const morePrependGroups = Array.isArray(rawPrependGroups)
? (rawPrependGroups as IProxyGroupConfig[])
: [];
const rawAppendGroups = moreGroupsObj?.["append"];
const moreAppendGroups = Array.isArray(rawAppendGroups)
? (rawAppendGroups as IProxyGroupConfig[])
: [];
const rawDeleteGroups = moreGroupsObj?.["delete"];
const moreDeleteGroups: Array<string | { name: string }> = Array.isArray(
rawDeleteGroups,
)
? (rawDeleteGroups as Array<string | { name: string }>)
: [];
const groups = morePrependGroups.concat(
originGroups.filter((group: any) => {
if (group.name) {
@@ -376,14 +409,16 @@ export const RulesEditorViewer = (props: Props) => {
moreAppendGroups,
);
const originRuleSetObj = yaml.load(data) as { "rule-providers": {} } | null;
const originRuleSetObj = yaml.load(data) as {
"rule-providers": Record<string, unknown>;
} | null;
const originRuleSet = originRuleSetObj?.["rule-providers"] || {};
const moreRuleSetObj = yaml.load(mergeData) as {
"rule-providers": {};
"rule-providers": Record<string, unknown>;
} | null;
const moreRuleSet = moreRuleSetObj?.["rule-providers"] || {};
const globalRuleSetObj = yaml.load(globalMergeData) as {
"rule-providers": {};
"rule-providers": Record<string, unknown>;
} | null;
const globalRuleSet = globalRuleSetObj?.["rule-providers"] || {};
const ruleSet = Object.assign(
@@ -393,12 +428,16 @@ export const RulesEditorViewer = (props: Props) => {
globalRuleSet,
);
const originSubRuleObj = yaml.load(data) as { "sub-rules": {} } | null;
const originSubRuleObj = yaml.load(data) as {
"sub-rules": Record<string, unknown>;
} | null;
const originSubRule = originSubRuleObj?.["sub-rules"] || {};
const moreSubRuleObj = yaml.load(mergeData) as { "sub-rules": {} } | null;
const moreSubRuleObj = yaml.load(mergeData) as {
"sub-rules": Record<string, unknown>;
} | null;
const moreSubRule = moreSubRuleObj?.["sub-rules"] || {};
const globalSubRuleObj = yaml.load(globalMergeData) as {
"sub-rules": {};
"sub-rules": Record<string, unknown>;
} | null;
const globalSubRule = globalSubRuleObj?.["sub-rules"] || {};
const subRule = Object.assign(
@@ -413,13 +452,13 @@ export const RulesEditorViewer = (props: Props) => {
setRuleSetList(Object.keys(ruleSet));
setSubRuleList(Object.keys(subRule));
setRuleList(rulesObj?.rules || []);
};
}, [groupsUid, mergeUid, profileUid]);
useEffect(() => {
if (!open) return;
fetchContent();
fetchProfile();
}, [open]);
}, [fetchContent, fetchProfile, open]);
const validateRule = () => {
if ((ruleType.required ?? true) && !ruleContent) {
@@ -626,10 +665,10 @@ export const RulesEditorViewer = (props: Props) => {
return x;
})}
>
{filteredPrependSeq.map((item, index) => {
{filteredPrependSeq.map((item) => {
return (
<RuleItem
key={`${item}-${index}`}
key={item}
type="prepend"
ruleRaw={item}
onDelete={() => {
@@ -647,7 +686,7 @@ export const RulesEditorViewer = (props: Props) => {
const newIndex = index - shift;
return (
<RuleItem
key={`${filteredRuleList[newIndex]}-${index}`}
key={filteredRuleList[newIndex]}
type={
deleteSeq.includes(filteredRuleList[newIndex])
? "delete"
@@ -682,10 +721,10 @@ export const RulesEditorViewer = (props: Props) => {
return x;
})}
>
{filteredAppendSeq.map((item, index) => {
{filteredAppendSeq.map((item) => {
return (
<RuleItem
key={`${item}-${index}`}
key={item}
type="append"
ruleRaw={item}
onDelete={() => {