diff --git a/src/components/profile/editor-viewer.tsx b/src/components/profile/editor-viewer.tsx index a095919f..c28f5738 100644 --- a/src/components/profile/editor-viewer.tsx +++ b/src/components/profile/editor-viewer.tsx @@ -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 = (props: Props) => { const { open = false, - title = t("Edit File"), - initialData = Promise.resolve(""), + title, + initialData, readOnly = false, language = "yaml", schema, @@ -97,6 +97,12 @@ export const EditorViewer = (props: Props) => { onClose, } = props; + const resolvedTitle = title ?? t("Edit File"); + const resolvedInitialData = useMemo( + () => initialData ?? Promise.resolve(""), + [initialData], + ); + const editorRef = useRef(undefined); const prevData = useRef(""); const currData = useRef(""); @@ -111,7 +117,7 @@ export const EditorViewer = (props: Props) => { 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 = (props: Props) => { 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 = (props: Props) => { } }); - 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 = (props: Props) => { editorRef.current?.dispose(); editorRef.current = undefined; }; - }, []); + }, [editorResize]); return ( - {title} + {resolvedTitle} { 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) => { /> )} { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/src/components/profile/groups-editor-viewer.tsx b/src/components/profile/groups-editor-viewer.tsx index 6ec594b0..7b14a68f 100644 --- a/src/components/profile/groups-editor-viewer.tsx +++ b/src/components/profile/groups-editor-viewer.tsx @@ -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; } | null; const originProvider = originProviderObj?.["proxy-providers"] || {}; const moreProviderObj = yaml.load(mergeData) as { - "proxy-providers": {}; + "proxy-providers": Record; } | null; const moreProvider = moreProviderObj?.["proxy-providers"] || {}; const globalProviderObj = yaml.load(globalMergeData) as { - "proxy-providers": {}; + "proxy-providers": Record; } | 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 ( { @@ -834,7 +845,7 @@ export const GroupsEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x.name; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( { diff --git a/src/components/profile/log-viewer.tsx b/src/components/profile/log-viewer.tsx index adcbd78e..9375ace3 100644 --- a/src/components/profile/log-viewer.tsx +++ b/src/components/profile/log-viewer.tsx @@ -37,8 +37,8 @@ export const LogViewer = (props: Props) => { pb: 1, }} > - {logInfo.map(([level, log], index) => ( - + {logInfo.map(([level, log]) => ( + { 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(null); + const [anchorEl, setAnchorEl] = useState(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 | undefined; // 处理定时器更新事件 - 这个事件专门用于通知定时器变更 - const handleTimerUpdate = (event: any) => { - const updatedUid = event.payload as string; + const handleTimerUpdate = (event: Event) => { + const source = event as CustomEvent & { 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 | 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 ( { 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)} /> diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index 6d113357..2ead4b94 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -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(null); + const [anchorEl, setAnchorEl] = useState(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 && ( setLogOpen(false)} /> )} diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index b66a216d..7c7ca377 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -45,23 +45,19 @@ export function ProfileViewer({ onChange, ref }: ProfileViewerProps) { // file input const fileDataRef = useRef(null); - const { - control, - watch, - register: _register, - ...formIns - } = useForm({ - defaultValues: { - type: "remote", - name: "", - desc: "", - url: "", - option: { - with_proxy: false, - self_proxy: false, + const { control, watch, setValue, reset, handleSubmit, getValues } = + useForm({ + 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" && ( { - formIns.setValue("name", formIns.getValues("name") || file.name); + setValue("name", getValues("name") || file.name); fileDataRef.current = val; }} /> diff --git a/src/components/profile/proxies-editor-viewer.tsx b/src/components/profile/proxies-editor-viewer.tsx index 3ec18f3b..d1c19cec 100644 --- a/src/components/profile/proxies-editor-viewer.tsx +++ b/src/components/profile/proxies-editor-viewer.tsx @@ -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 | 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 | 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 ( { @@ -380,7 +403,7 @@ export const ProxiesEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x.name; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( { diff --git a/src/components/profile/proxy-item.tsx b/src/components/profile/proxy-item.tsx index 7efb878f..4b8a561f 100644 --- a/src/components/profile/proxy-item.tsx +++ b/src/components/profile/proxy-item.tsx @@ -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 ( { })} > { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/src/components/profile/rule-item.tsx b/src/components/profile/rule-item.tsx index 03e5fb54..587913f1 100644 --- a/src/components/profile/rule-item.tsx +++ b/src/components/profile/rule-item.tsx @@ -95,11 +95,13 @@ export const RuleItem = (props: Props) => { } - secondaryTypographyProps={{ - sx: { - display: "flex", - alignItems: "center", - color: "#ccc", + slotProps={{ + secondary: { + sx: { + display: "flex", + alignItems: "center", + color: "#ccc", + }, }, }} /> diff --git a/src/components/profile/rules-editor-viewer.tsx b/src/components/profile/rules-editor-viewer.tsx index fe27bfc2..2e8bb75e 100644 --- a/src/components/profile/rules-editor-viewer.tsx +++ b/src/components/profile/rules-editor-viewer.tsx @@ -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 | 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 = Array.isArray( + rawDeleteGroups, + ) + ? (rawDeleteGroups as Array) + : []; 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; + } | null; const originRuleSet = originRuleSetObj?.["rule-providers"] || {}; const moreRuleSetObj = yaml.load(mergeData) as { - "rule-providers": {}; + "rule-providers": Record; } | null; const moreRuleSet = moreRuleSetObj?.["rule-providers"] || {}; const globalRuleSetObj = yaml.load(globalMergeData) as { - "rule-providers": {}; + "rule-providers": Record; } | 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; + } | null; const originSubRule = originSubRuleObj?.["sub-rules"] || {}; - const moreSubRuleObj = yaml.load(mergeData) as { "sub-rules": {} } | null; + const moreSubRuleObj = yaml.load(mergeData) as { + "sub-rules": Record; + } | null; const moreSubRule = moreSubRuleObj?.["sub-rules"] || {}; const globalSubRuleObj = yaml.load(globalMergeData) as { - "sub-rules": {}; + "sub-rules": Record; } | 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 ( { @@ -647,7 +686,7 @@ export const RulesEditorViewer = (props: Props) => { const newIndex = index - shift; return ( { return x; })} > - {filteredAppendSeq.map((item, index) => { + {filteredAppendSeq.map((item) => { return ( {