fix(groups-editor): persist deletions/restorations and normalize YAML

Prevent state resets while editing groups so deletions/restorations persist instead of being overwritten.
Ensure YAML is normalized and the latest visual state is saved.

- Add `normalizeDeleteSeq` to handle legacy `{name: ...}` entries and `buildGroupsYaml` for consistent serialization.
- Guard reassigning `deleteSeq` unless normalized value changes to avoid effect loops.
- Normalize proxy deletions and deduplicate policy names without extra backend writes.
- Split “on open” effect from proxy-policy refresh; toggling delete no longer triggers `fetchContent()`.
- Write composed YAML in `handleSave`, keep `currData`/`prevData` aligned, and provide accurate payloads to `onSave`.
This commit is contained in:
Slinetrac
2025-10-27 16:20:47 +08:00
parent 11035db307
commit c234d1dc16

View File

@@ -73,6 +73,50 @@ interface Props {
const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"]; const builtinProxyPolicies = ["DIRECT", "REJECT", "REJECT-DROP", "PASS"];
const normalizeDeleteSeq = (input?: unknown): string[] => {
if (!Array.isArray(input)) {
return [];
}
const names = input
.map((item) => {
if (typeof item === "string") {
return item;
}
if (
item &&
typeof item === "object" &&
"name" in item &&
typeof (item as { name: unknown }).name === "string"
) {
return (item as { name: string }).name;
}
return undefined;
})
.filter(
(name): name is string => typeof name === "string" && name.length > 0,
);
return Array.from(new Set(names));
};
const buildGroupsYaml = (
prepend: IProxyGroupConfig[],
append: IProxyGroupConfig[],
deleteList: string[],
) => {
return yaml.dump(
{
prepend,
append,
delete: deleteList,
},
{ forceQuotes: true },
);
};
export const GroupsEditorViewer = (props: Props) => { export const GroupsEditorViewer = (props: Props) => {
const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } = const { mergeUid, proxiesUid, profileUid, property, open, onClose, onSave } =
props; props;
@@ -172,7 +216,16 @@ export const GroupsEditorViewer = (props: Props) => {
setPrependSeq(obj?.prepend || []); setPrependSeq(obj?.prepend || []);
setAppendSeq(obj?.append || []); setAppendSeq(obj?.append || []);
setDeleteSeq(obj?.delete || []); setDeleteSeq((prev) => {
const normalized = normalizeDeleteSeq(obj?.delete);
if (
normalized.length === prev.length &&
normalized.every((item, index) => item === prev[index])
) {
return prev;
}
return normalized;
});
setPrevData(data); setPrevData(data);
setCurrData(data); setCurrData(data);
@@ -187,7 +240,16 @@ export const GroupsEditorViewer = (props: Props) => {
startTransition(() => { startTransition(() => {
setPrependSeq(obj?.prepend ?? []); setPrependSeq(obj?.prepend ?? []);
setAppendSeq(obj?.append ?? []); setAppendSeq(obj?.append ?? []);
setDeleteSeq(obj?.delete ?? []); setDeleteSeq((prev) => {
const normalized = normalizeDeleteSeq(obj?.delete);
if (
normalized.length === prev.length &&
normalized.every((item, index) => item === prev[index])
) {
return prev;
}
return normalized;
});
}); });
}, [currData, visualization]); }, [currData, visualization]);
@@ -196,12 +258,7 @@ export const GroupsEditorViewer = (props: Props) => {
if (prependSeq && appendSeq && deleteSeq) { if (prependSeq && appendSeq && deleteSeq) {
const serialize = () => { const serialize = () => {
try { try {
setCurrData( setCurrData(buildGroupsYaml(prependSeq, appendSeq, deleteSeq));
yaml.dump(
{ prepend: prependSeq, append: appendSeq, delete: deleteSeq },
{ forceQuotes: true },
),
);
} catch (e) { } catch (e) {
console.warn("[GroupsEditorViewer] yaml.dump failed:", e); console.warn("[GroupsEditorViewer] yaml.dump failed:", e);
// 防止异常导致UI卡死 // 防止异常导致UI卡死
@@ -227,30 +284,37 @@ export const GroupsEditorViewer = (props: Props) => {
const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null; const moreProxiesObj = yaml.load(proxiesData) as ISeqProfileConfig | null;
const morePrependProxies = moreProxiesObj?.prepend || []; const morePrependProxies = moreProxiesObj?.prepend || [];
const moreAppendProxies = moreProxiesObj?.append || []; const moreAppendProxies = moreProxiesObj?.append || [];
const moreDeleteProxies = const moreDeleteProxies = normalizeDeleteSeq(moreProxiesObj?.delete);
moreProxiesObj?.delete || ([] as string[] | { name: string }[]);
const proxies = morePrependProxies.concat( const proxies = morePrependProxies.concat(
originProxies.filter((proxy: any) => { originProxies.filter((proxy: any) => {
if (proxy.name) { const proxyName =
return !moreDeleteProxies.includes(proxy.name); typeof proxy === "string"
} else { ? proxy
return !moreDeleteProxies.includes(proxy); : (proxy?.name as string | undefined);
} return proxyName ? !moreDeleteProxies.includes(proxyName) : true;
}), }),
moreAppendProxies, moreAppendProxies,
); );
setProxyPolicyList( const proxyNames = proxies
builtinProxyPolicies.concat( .map((proxy: any) =>
prependSeq.map((group: IProxyGroupConfig) => group.name), typeof proxy === "string" ? proxy : (proxy?.name as string | undefined),
originGroupsObj?.["proxy-groups"] )
.map((group: IProxyGroupConfig) => group.name) .filter(
.filter((name) => !deleteSeq.includes(name)) || [], (name): name is string => typeof name === "string" && name.length > 0,
appendSeq.map((group: IProxyGroupConfig) => group.name), );
proxies.map((proxy: any) => proxy.name),
), const computedPolicyList = 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),
proxyNames,
); );
setProxyPolicyList(Array.from(new Set(computedPolicyList)));
}, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]); }, [appendSeq, deleteSeq, prependSeq, profileUid, proxiesUid]);
const fetchProfile = useCallback(async () => { const fetchProfile = useCallback(async () => {
const data = await readProfileFile(profileUid); const data = await readProfileFile(profileUid);
@@ -291,21 +355,16 @@ export const GroupsEditorViewer = (props: Props) => {
setInterfaceNameList(list); setInterfaceNameList(list);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!open) return;
fetchProxyPolicy(); fetchProxyPolicy();
}, [fetchProxyPolicy]); }, [fetchProxyPolicy, open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
fetchContent(); fetchContent();
fetchProxyPolicy();
fetchProfile(); fetchProfile();
getInterfaceNameList(); getInterfaceNameList();
}, [ }, [fetchContent, fetchProfile, getInterfaceNameList, open]);
fetchContent,
fetchProfile,
fetchProxyPolicy,
getInterfaceNameList,
open,
]);
const validateGroup = () => { const validateGroup = () => {
const group = formIns.getValues(); const group = formIns.getValues();
@@ -316,9 +375,18 @@ export const GroupsEditorViewer = (props: Props) => {
const handleSave = useLockFn(async () => { const handleSave = useLockFn(async () => {
try { try {
await saveProfileFile(property, currData); const nextData = visualization
? buildGroupsYaml(prependSeq, appendSeq, deleteSeq)
: currData;
if (visualization) {
setCurrData(nextData);
}
await saveProfileFile(property, nextData);
showNotice("success", t("Saved Successfully")); showNotice("success", t("Saved Successfully"));
onSave?.(prevData, currData); setPrevData(nextData);
onSave?.(prevData, nextData);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
showNotice("error", err.toString()); showNotice("error", err.toString());