refactor:replace state machine with reducer
This commit is contained in:
@@ -31,7 +31,14 @@ import { readTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useLockFn } from "ahooks";
|
||||
import type { TFunction } from "i18next";
|
||||
import { throttle } from "lodash-es";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
@@ -77,12 +84,81 @@ type SwitchRequest = {
|
||||
notifySuccess: boolean;
|
||||
};
|
||||
|
||||
interface ProfileSwitchMachineOptions {
|
||||
interface SwitchState {
|
||||
switching: SwitchRequest | null;
|
||||
queued: SwitchRequest | null;
|
||||
status: "idle" | "running";
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
type SwitchAction =
|
||||
| { type: "REQUEST"; payload: SwitchRequest }
|
||||
| { type: "RUN_FINISHED"; payload: { error?: string | null } };
|
||||
|
||||
const initialSwitchState: SwitchState = {
|
||||
switching: null,
|
||||
queued: null,
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
const switchReducer = (
|
||||
state: SwitchState,
|
||||
action: SwitchAction,
|
||||
): SwitchState => {
|
||||
switch (action.type) {
|
||||
case "REQUEST": {
|
||||
const payload = action.payload;
|
||||
if (state.switching) {
|
||||
if (state.switching.profile === payload.profile) {
|
||||
const notifySuccess =
|
||||
state.switching.notifySuccess || payload.notifySuccess;
|
||||
return {
|
||||
...state,
|
||||
switching: { profile: payload.profile, notifySuccess },
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
queued: payload,
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
switching: payload,
|
||||
queued: null,
|
||||
status: "running",
|
||||
lastError: null,
|
||||
};
|
||||
}
|
||||
case "RUN_FINISHED": {
|
||||
const nextError = action.payload.error ?? null;
|
||||
if (state.queued) {
|
||||
return {
|
||||
switching: state.queued,
|
||||
queued: null,
|
||||
status: "running",
|
||||
lastError: nextError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
switching: null,
|
||||
queued: null,
|
||||
status: "idle",
|
||||
lastError: nextError,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
interface ProfileSwitchControllerOptions {
|
||||
getCurrentProfileId: () => string | undefined;
|
||||
patchProfiles: (
|
||||
value: Partial<IProfilesConfig>,
|
||||
signal: AbortSignal,
|
||||
) => Promise<boolean>;
|
||||
patchProfiles: (value: Partial<IProfilesConfig>) => Promise<void>;
|
||||
mutateLogs: () => Promise<void>;
|
||||
closeAllConnections: () => Promise<void>;
|
||||
activateSelected: () => Promise<void>;
|
||||
@@ -90,7 +166,7 @@ interface ProfileSwitchMachineOptions {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const useProfileSwitchMachine = ({
|
||||
const useProfileSwitchController = ({
|
||||
getCurrentProfileId,
|
||||
patchProfiles,
|
||||
mutateLogs,
|
||||
@@ -98,142 +174,96 @@ const useProfileSwitchMachine = ({
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
}: ProfileSwitchMachineOptions) => {
|
||||
const switchingProfileRef = useRef<string | null>(null);
|
||||
const pendingProfileRef = useRef<SwitchRequest | null>(null);
|
||||
const processingQueueRef = useRef(false);
|
||||
const requestSequenceRef = useRef(0);
|
||||
|
||||
const cleanupSwitchState = useCallback(
|
||||
(profile: string, sequence: number) => {
|
||||
setActivatings((prev) => prev.filter((id) => id !== profile));
|
||||
if (switchingProfileRef.current === profile) {
|
||||
switchingProfileRef.current = null;
|
||||
}
|
||||
debugProfileSwitch("SWITCH_END", profile, `序列号: ${sequence}`);
|
||||
},
|
||||
[setActivatings],
|
||||
);
|
||||
|
||||
const runProfileSwitch = useCallback(
|
||||
async (request: SwitchRequest) => {
|
||||
const { profile, notifySuccess } = request;
|
||||
const currentSequence = ++requestSequenceRef.current;
|
||||
debugProfileSwitch("NEW_REQUEST", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
switchingProfileRef.current = profile;
|
||||
debugProfileSwitch("SWITCH_START", profile, `序列号: ${currentSequence}`);
|
||||
|
||||
setActivatings((prev) => {
|
||||
if (prev.includes(profile)) return prev;
|
||||
return [...prev, profile];
|
||||
});
|
||||
|
||||
const isOutdated = () => currentSequence !== requestSequenceRef.current;
|
||||
const controller = new AbortController();
|
||||
|
||||
try {
|
||||
if (isOutdated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const requestPromise = patchProfiles(
|
||||
{ current: profile },
|
||||
controller.signal,
|
||||
);
|
||||
const success = await requestPromise;
|
||||
|
||||
if (isOutdated()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mutateLogs();
|
||||
await closeAllConnections();
|
||||
|
||||
if (notifySuccess && success) {
|
||||
showNotice("success", t("Profile Switched"), 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isOutdated()) return;
|
||||
activateSelected().catch((err) =>
|
||||
console.warn("Failed to activate selected proxies:", err),
|
||||
);
|
||||
}, 50);
|
||||
} catch (error: any) {
|
||||
console.error(`[Profile] 切换失败:`, error);
|
||||
showNotice("error", error?.message || error.toString(), 4000);
|
||||
} finally {
|
||||
cleanupSwitchState(profile, currentSequence);
|
||||
}
|
||||
},
|
||||
[
|
||||
activateSelected,
|
||||
cleanupSwitchState,
|
||||
closeAllConnections,
|
||||
mutateLogs,
|
||||
patchProfiles,
|
||||
setActivatings,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const processSwitchQueue = useCallback(() => {
|
||||
if (processingQueueRef.current) return;
|
||||
|
||||
const run = async () => {
|
||||
processingQueueRef.current = true;
|
||||
try {
|
||||
while (true) {
|
||||
const next = pendingProfileRef.current;
|
||||
if (!next) break;
|
||||
pendingProfileRef.current = null;
|
||||
await runProfileSwitch(next);
|
||||
}
|
||||
} finally {
|
||||
processingQueueRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
run().catch((error) => {
|
||||
console.error("[Profile] 处理切换队列失败:", error);
|
||||
});
|
||||
}, [runProfileSwitch]);
|
||||
}: ProfileSwitchControllerOptions) => {
|
||||
const [state, dispatch] = useReducer(switchReducer, initialSwitchState);
|
||||
const runningProfileRef = useRef<string | null>(null);
|
||||
|
||||
const requestSwitch = useCallback(
|
||||
(profile: string, notifySuccess: boolean) => {
|
||||
const currentProfileId = getCurrentProfileId();
|
||||
if (currentProfileId === profile && !notifySuccess) {
|
||||
const currentId = getCurrentProfileId();
|
||||
if (!notifySuccess && currentId === profile && state.status === "idle") {
|
||||
debugProfileSwitch("ALREADY_CURRENT_IGNORED", profile);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSwitching = switchingProfileRef.current;
|
||||
|
||||
if (currentSwitching === profile) {
|
||||
debugProfileSwitch("DUPLICATE_SWITCH_BLOCKED", profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSwitching && currentSwitching !== profile) {
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingProfileRef.current?.profile === profile) {
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProfileRef.current = { profile, notifySuccess };
|
||||
processSwitchQueue();
|
||||
dispatch({ type: "REQUEST", payload: { profile, notifySuccess } });
|
||||
},
|
||||
[getCurrentProfileId, processSwitchQueue],
|
||||
[getCurrentProfileId, state.status],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status !== "running" || !state.switching) return;
|
||||
const { profile, notifySuccess } = state.switching;
|
||||
|
||||
if (runningProfileRef.current === profile) return;
|
||||
runningProfileRef.current = profile;
|
||||
|
||||
let cancelled = false;
|
||||
let delayTimer: number | null = null;
|
||||
setActivatings((prev) => {
|
||||
if (prev.includes(profile)) return prev;
|
||||
return [...prev, profile];
|
||||
});
|
||||
|
||||
(async () => {
|
||||
let errorMessage: string | null = null;
|
||||
try {
|
||||
await patchProfiles({ current: profile });
|
||||
if (cancelled) return;
|
||||
|
||||
await mutateLogs();
|
||||
await closeAllConnections();
|
||||
|
||||
if (!cancelled && notifySuccess) {
|
||||
showNotice("success", t("Profile Switched"), 1000);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
await new Promise<void>((resolve) => {
|
||||
delayTimer = window.setTimeout(() => {
|
||||
delayTimer = null;
|
||||
resolve();
|
||||
}, 50);
|
||||
});
|
||||
await activateSelected();
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
errorMessage = error?.message || String(error);
|
||||
showNotice("error", errorMessage, 4000);
|
||||
}
|
||||
} finally {
|
||||
setActivatings((prev) => prev.filter((id) => id !== profile));
|
||||
runningProfileRef.current = null;
|
||||
if (!cancelled) {
|
||||
dispatch({ type: "RUN_FINISHED", payload: { error: errorMessage } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (delayTimer !== null) {
|
||||
clearTimeout(delayTimer);
|
||||
delayTimer = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
state.status,
|
||||
state.switching,
|
||||
patchProfiles,
|
||||
mutateLogs,
|
||||
closeAllConnections,
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
]);
|
||||
|
||||
return {
|
||||
requestSwitch,
|
||||
switchingProfileRef,
|
||||
switchingProfile: state.switching?.profile ?? null,
|
||||
status: state.status,
|
||||
lastError: state.lastError,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -255,7 +285,7 @@ const normalizeProfileUrl = (value?: string) => {
|
||||
} catch {
|
||||
const schemeNormalized = trimmed.replace(
|
||||
/^([a-z]+):\/\//i,
|
||||
(match, scheme: string) => `${scheme.toLowerCase()}://`,
|
||||
(_match, scheme: string) => `${scheme.toLowerCase()}://`,
|
||||
);
|
||||
return schemeNormalized.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -553,15 +583,16 @@ const ProfilePage = () => {
|
||||
await closeAllConnections();
|
||||
}, []);
|
||||
|
||||
const { requestSwitch, switchingProfileRef } = useProfileSwitchMachine({
|
||||
getCurrentProfileId,
|
||||
patchProfiles,
|
||||
mutateLogs: memoizedMutateLogs,
|
||||
closeAllConnections: closeConnections,
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
});
|
||||
const { requestSwitch, switchingProfile: switchingProfileId } =
|
||||
useProfileSwitchController({
|
||||
getCurrentProfileId,
|
||||
patchProfiles: (payload) => patchProfiles(payload),
|
||||
mutateLogs: memoizedMutateLogs,
|
||||
closeAllConnections: closeConnections,
|
||||
activateSelected,
|
||||
setActivatings,
|
||||
t,
|
||||
});
|
||||
|
||||
// 强化的刷新策略
|
||||
const performRobustRefresh = async (
|
||||
@@ -651,7 +682,7 @@ const ProfilePage = () => {
|
||||
|
||||
const onSelect = (current: string, force: boolean) => {
|
||||
// 阻止重复点击或已激活的profile
|
||||
if (switchingProfileRef.current === current) {
|
||||
if (switchingProfileId === current) {
|
||||
debugProfileSwitch("DUPLICATE_CLICK_IGNORED", current);
|
||||
return;
|
||||
}
|
||||
@@ -672,9 +703,9 @@ const ProfilePage = () => {
|
||||
}, [current, mutateProfiles, requestSwitch]);
|
||||
|
||||
const onEnhance = useLockFn(async (notifySuccess: boolean) => {
|
||||
if (switchingProfileRef.current) {
|
||||
if (switchingProfileId) {
|
||||
console.log(
|
||||
`[Profile] 有profile正在切换中(${switchingProfileRef.current}),跳过enhance操作`,
|
||||
`[Profile] 有profile正在切换中(${switchingProfileId}),跳过enhance操作`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -693,7 +724,9 @@ const ProfilePage = () => {
|
||||
} finally {
|
||||
// 保留正在切换的profile,清除其他状态
|
||||
setActivatings((prev) =>
|
||||
prev.filter((id) => id === switchingProfileRef.current),
|
||||
switchingProfileId
|
||||
? prev.filter((id) => id === switchingProfileId)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -834,7 +867,7 @@ const ProfilePage = () => {
|
||||
});
|
||||
|
||||
const mode = useThemeMode();
|
||||
const islight = mode === "light" ? true : false;
|
||||
const islight = mode === "light";
|
||||
const dividercolor = islight
|
||||
? "rgba(0, 0, 0, 0.06)"
|
||||
: "rgba(255, 255, 255, 0.06)";
|
||||
|
||||
Reference in New Issue
Block a user