diff --git a/src/renderer/src/hooks/create-config-context.tsx b/src/renderer/src/hooks/create-config-context.tsx new file mode 100644 index 0000000..1d9d180 --- /dev/null +++ b/src/renderer/src/hooks/create-config-context.tsx @@ -0,0 +1,72 @@ +import React, { createContext, useContext, ReactNode, useCallback, useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { showError } from '@renderer/utils/error-display' +import useSWR, { KeyedMutator } from 'swr' + +interface ConfigContextValue { + config: T | undefined + mutate: KeyedMutator +} + +interface CreateConfigContextOptions { + swrKey: string + fetcher: () => Promise + ipcEvent: string +} + +export function createConfigContext(options: CreateConfigContextOptions) { + const { swrKey, fetcher, ipcEvent } = options + const Context = createContext | undefined>(undefined) + + const Provider: React.FC<{ children: ReactNode }> = ({ children }) => { + const { data: config, mutate } = useSWR(swrKey, fetcher) + + useEffect(() => { + const handler = (): void => { + mutate() + } + window.electron.ipcRenderer.on(ipcEvent, handler) + return () => { + window.electron.ipcRenderer.removeListener(ipcEvent, handler) + } + }, [mutate]) + + return {children} + } + + const useConfig = (): ConfigContextValue => { + const context = useContext(Context) + if (!context) { + throw new Error(`useConfig must be used within Provider`) + } + return context + } + + return { Provider, useConfig, Context } +} + +interface ActionOptions { + errorKey: string + updateTray?: boolean +} + +export function useConfigAction( + mutate: KeyedMutator, + action: () => Promise, + options: ActionOptions +): () => Promise { + const { t } = useTranslation() + + return useCallback(async () => { + try { + await action() + } catch (e) { + await showError(e, t(options.errorKey)) + } finally { + mutate() + if (options.updateTray) { + window.electron.ipcRenderer.send('updateTrayMenu') + } + } + }, [mutate, action, t, options.errorKey, options.updateTray]) +} diff --git a/src/renderer/src/hooks/use-app-config.tsx b/src/renderer/src/hooks/use-app-config.tsx index 274b0e1..30c4b87 100644 --- a/src/renderer/src/hooks/use-app-config.tsx +++ b/src/renderer/src/hooks/use-app-config.tsx @@ -1,51 +1,60 @@ -import React, { createContext, useContext, ReactNode } from 'react' +import React, { ReactNode, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { showError } from '@renderer/utils/error-display' -import useSWR from 'swr' +import { createConfigContext } from './create-config-context' import { getAppConfig, patchAppConfig as patch } from '@renderer/utils/ipc' +const { Provider, useConfig } = createConfigContext({ + swrKey: 'getAppConfig', + fetcher: getAppConfig, + ipcEvent: 'appConfigUpdated' +}) + interface AppConfigContextType { appConfig: IAppConfig | undefined mutateAppConfig: () => void patchAppConfig: (value: Partial) => Promise } -const AppConfigContext = createContext(undefined) - export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + return ( + + {children} + + ) +} + +const AppConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => { + const { config, mutate } = useConfig() const { t } = useTranslation() - const { data: appConfig, mutate: mutateAppConfig } = useSWR('getConfig', () => getAppConfig()) - const patchAppConfig = async (value: Partial): Promise => { - try { - await patch(value) - } catch (e) { - await showError(e, t('common.error.updateAppConfigFailed')) - } finally { - mutateAppConfig() - } - } - - React.useEffect(() => { - const handler = (): void => { - mutateAppConfig() - } - window.electron.ipcRenderer.on('appConfigUpdated', handler) - return (): void => { - window.electron.ipcRenderer.removeListener('appConfigUpdated', handler) - } - }, []) + const patchAppConfig = useCallback( + async (value: Partial): Promise => { + try { + await patch(value) + } catch (e) { + await showError(e, t('common.error.updateAppConfigFailed')) + } finally { + mutate() + } + }, + [mutate, t] + ) return ( - + {children} ) } +const AppConfigContext = React.createContext(undefined) + export const useAppConfig = (): AppConfigContextType => { - const context = useContext(AppConfigContext) - if (context === undefined) { + const context = React.useContext(AppConfigContext) + if (!context) { throw new Error('useAppConfig must be used within an AppConfigProvider') } return context diff --git a/src/renderer/src/hooks/use-override-config.tsx b/src/renderer/src/hooks/use-override-config.tsx index 448ef33..daf7d5a 100644 --- a/src/renderer/src/hooks/use-override-config.tsx +++ b/src/renderer/src/hooks/use-override-config.tsx @@ -1,7 +1,7 @@ -import React, { createContext, useContext, ReactNode } from 'react' +import React, { ReactNode, useCallback } from 'react' import { useTranslation } from 'react-i18next' import { showError } from '@renderer/utils/error-display' -import useSWR from 'swr' +import { createConfigContext } from './create-config-context' import { getOverrideConfig, setOverrideConfig as set, @@ -10,6 +10,12 @@ import { updateOverrideItem as update } from '@renderer/utils/ipc' +const { Provider, useConfig } = createConfigContext({ + swrKey: 'getOverrideConfig', + fetcher: getOverrideConfig, + ipcEvent: 'overrideConfigUpdated' +}) + interface OverrideConfigContextType { overrideConfig: IOverrideConfig | undefined setOverrideConfig: (config: IOverrideConfig) => Promise @@ -19,60 +25,59 @@ interface OverrideConfigContextType { removeOverrideItem: (id: string) => Promise } -const OverrideConfigContext = createContext(undefined) +const OverrideConfigContext = React.createContext(undefined) export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + return ( + + {children} + + ) +} + +const OverrideConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => { + const { config, mutate } = useConfig() const { t } = useTranslation() - const { data: overrideConfig, mutate: mutateOverrideConfig } = useSWR('getOverrideConfig', () => - getOverrideConfig() + + const withErrorHandling = useCallback( + (action: () => Promise, errorKey: string) => async () => { + try { + await action() + } catch (e) { + await showError(e, t(errorKey)) + } finally { + mutate() + } + }, + [mutate, t] ) - const setOverrideConfig = async (config: IOverrideConfig): Promise => { - try { - await set(config) - } catch (e) { - await showError(e, t('common.error.saveOverrideConfigFailed')) - } finally { - mutateOverrideConfig() - } - } + const setOverrideConfig = useCallback( + (cfg: IOverrideConfig) => withErrorHandling(() => set(cfg), 'common.error.saveOverrideConfigFailed')(), + [withErrorHandling] + ) - const addOverrideItem = async (item: Partial): Promise => { - try { - await add(item) - } catch (e) { - await showError(e, t('common.error.addOverrideFailed')) - } finally { - mutateOverrideConfig() - } - } + const addOverrideItem = useCallback( + (item: Partial) => withErrorHandling(() => add(item), 'common.error.addOverrideFailed')(), + [withErrorHandling] + ) - const removeOverrideItem = async (id: string): Promise => { - try { - await remove(id) - } catch (e) { - await showError(e, t('common.error.deleteOverrideFailed')) - } finally { - mutateOverrideConfig() - } - } + const removeOverrideItem = useCallback( + (id: string) => withErrorHandling(() => remove(id), 'common.error.deleteOverrideFailed')(), + [withErrorHandling] + ) - const updateOverrideItem = async (item: IOverrideItem): Promise => { - try { - await update(item) - } catch (e) { - await showError(e, t('common.error.updateOverrideFailed')) - } finally { - mutateOverrideConfig() - } - } + const updateOverrideItem = useCallback( + (item: IOverrideItem) => withErrorHandling(() => update(item), 'common.error.updateOverrideFailed')(), + [withErrorHandling] + ) return ( = ({ chil } export const useOverrideConfig = (): OverrideConfigContextType => { - const context = useContext(OverrideConfigContext) - if (context === undefined) { + const context = React.useContext(OverrideConfigContext) + if (!context) { throw new Error('useOverrideConfig must be used within an OverrideConfigProvider') } return context diff --git a/src/renderer/src/hooks/use-profile-config.tsx b/src/renderer/src/hooks/use-profile-config.tsx index 73ede17..dd4b097 100644 --- a/src/renderer/src/hooks/use-profile-config.tsx +++ b/src/renderer/src/hooks/use-profile-config.tsx @@ -1,7 +1,7 @@ -import React, { createContext, ReactNode, useContext } from 'react' +import React, { ReactNode, useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import { showError } from '@renderer/utils/error-display' -import useSWR from 'swr' +import { createConfigContext } from './create-config-context' import { addProfileItem as add, changeCurrentProfile as change, @@ -11,6 +11,12 @@ import { updateProfileItem as update } from '@renderer/utils/ipc' +const { Provider, useConfig } = createConfigContext({ + swrKey: 'getProfileConfig', + fetcher: getProfileConfig, + ipcEvent: 'profileConfigUpdated' +}) + interface ProfileConfigContextType { profileConfig: IProfileConfig | undefined setProfileConfig: (config: IProfileConfig) => Promise @@ -21,80 +27,64 @@ interface ProfileConfigContextType { changeCurrentProfile: (id: string) => Promise } -const ProfileConfigContext = createContext(undefined) +const ProfileConfigContext = React.createContext(undefined) export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => { - const { t } = useTranslation() - const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () => - getProfileConfig() + return ( + + {children} + ) - const targetProfileId = React.useRef(null) - const pendingTask = React.useRef | null>(null) +} - const setProfileConfig = async (config: IProfileConfig): Promise => { - try { - await set(config) - } catch (e) { - await showError(e, t('common.error.saveProfileConfigFailed')) - } finally { - mutateProfileConfig() - window.electron.ipcRenderer.send('updateTrayMenu') - } - } +const ProfileConfigContextWrapper: React.FC<{ children: ReactNode }> = ({ children }) => { + const { config, mutate } = useConfig() + const { t } = useTranslation() + const targetProfileId = useRef(null) + const pendingTask = useRef | null>(null) - const addProfileItem = async (item: Partial): Promise => { - try { - await add(item) - } catch (e) { - await showError(e, t('common.error.addProfileFailed')) - } finally { - mutateProfileConfig() - window.electron.ipcRenderer.send('updateTrayMenu') - } - } + const withErrorHandling = useCallback( + (action: () => Promise, errorKey: string, updateTray = true) => + async () => { + try { + await action() + } catch (e) { + await showError(e, t(errorKey)) + } finally { + mutate() + if (updateTray) { + window.electron.ipcRenderer.send('updateTrayMenu') + } + } + }, + [mutate, t] + ) - const removeProfileItem = async (id: string): Promise => { - try { - await remove(id) - } catch (e) { - await showError(e, t('common.error.deleteProfileFailed')) - } finally { - mutateProfileConfig() - window.electron.ipcRenderer.send('updateTrayMenu') - } - } + const setProfileConfig = useCallback( + (cfg: IProfileConfig) => + withErrorHandling(() => set(cfg), 'common.error.saveProfileConfigFailed')(), + [withErrorHandling] + ) - const updateProfileItem = async (item: IProfileItem): Promise => { - try { - await update(item) - } catch (e) { - await showError(e, t('common.error.updateProfileFailed')) - } finally { - mutateProfileConfig() - window.electron.ipcRenderer.send('updateTrayMenu') - } - } + const addProfileItem = useCallback( + (item: Partial) => + withErrorHandling(() => add(item), 'common.error.addProfileFailed')(), + [withErrorHandling] + ) - const changeCurrentProfile = async (id: string): Promise => { - if (targetProfileId.current === id) { - return - } + const removeProfileItem = useCallback( + (id: string) => withErrorHandling(() => remove(id), 'common.error.deleteProfileFailed')(), + [withErrorHandling] + ) - // 立即更新 UI 状态和托盘菜单,提供即时反馈 - if (profileConfig) { - const optimisticUpdate = { ...profileConfig, current: id } - mutateProfileConfig(optimisticUpdate, false) - window.electron.ipcRenderer.send('updateTrayMenu') - } + const updateProfileItem = useCallback( + (item: IProfileItem) => + withErrorHandling(() => update(item), 'common.error.updateProfileFailed')(), + [withErrorHandling] + ) - targetProfileId.current = id - await processChange() - } - - const processChange = async () => { - if (pendingTask.current) { - return - } + const processChange = useCallback(async () => { + if (pendingTask.current) return while (targetProfileId.current) { const targetId = targetProfileId.current @@ -102,41 +92,48 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ child pendingTask.current = change(targetId) try { - // 异步执行后台切换,不阻塞 UI await pendingTask.current } catch (e) { const errorMsg = (e as { message?: string })?.message || String(e) - // 处理 IPC 超时错误 if (errorMsg.includes('reply was never sent')) { - setTimeout(() => mutateProfileConfig(), 1000) + setTimeout(() => mutate(), 1000) } else { await showError(errorMsg, t('common.error.switchProfileFailed')) - mutateProfileConfig() + mutate() } } finally { pendingTask.current = null } } - } + }, [mutate, t]) + + const changeCurrentProfile = useCallback( + async (id: string) => { + if (targetProfileId.current === id) return + + if (config) { + mutate({ ...config, current: id }, false) + window.electron.ipcRenderer.send('updateTrayMenu') + } + + targetProfileId.current = id + await processChange() + }, + [config, mutate, processChange] + ) React.useEffect(() => { - const handler = (): void => { - mutateProfileConfig() - } - window.electron.ipcRenderer.on('profileConfigUpdated', handler) - return (): void => { - // 清理待处理任务,防止内存泄漏 + return () => { targetProfileId.current = null - window.electron.ipcRenderer.removeListener('profileConfigUpdated', handler) } }, []) return ( = ({ child } export const useProfileConfig = (): ProfileConfigContextType => { - const context = useContext(ProfileConfigContext) - if (context === undefined) { + const context = React.useContext(ProfileConfigContext) + if (!context) { throw new Error('useProfileConfig must be used within a ProfileConfigProvider') } return context