refactor: extract common config context logic into factory function

This commit is contained in:
xmk23333 2026-01-06 02:27:58 +08:00
parent 38389e0c3c
commit 2c638f56c0
4 changed files with 237 additions and 154 deletions

View File

@ -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<T> {
config: T | undefined
mutate: KeyedMutator<T>
}
interface CreateConfigContextOptions<T> {
swrKey: string
fetcher: () => Promise<T>
ipcEvent: string
}
export function createConfigContext<T>(options: CreateConfigContextOptions<T>) {
const { swrKey, fetcher, ipcEvent } = options
const Context = createContext<ConfigContextValue<T> | 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 <Context.Provider value={{ config, mutate }}>{children}</Context.Provider>
}
const useConfig = (): ConfigContextValue<T> => {
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<T>(
mutate: KeyedMutator<T>,
action: () => Promise<void>,
options: ActionOptions
): () => Promise<void> {
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])
}

View File

@ -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<IAppConfig>({
swrKey: 'getAppConfig',
fetcher: getAppConfig,
ipcEvent: 'appConfigUpdated'
})
interface AppConfigContextType {
appConfig: IAppConfig | undefined
mutateAppConfig: () => void
patchAppConfig: (value: Partial<IAppConfig>) => Promise<void>
}
const AppConfigContext = createContext<AppConfigContextType | undefined>(undefined)
export const AppConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<Provider>
<AppConfigContextWrapper>{children}</AppConfigContextWrapper>
</Provider>
)
}
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<IAppConfig>): Promise<void> => {
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<IAppConfig>): Promise<void> => {
try {
await patch(value)
} catch (e) {
await showError(e, t('common.error.updateAppConfigFailed'))
} finally {
mutate()
}
},
[mutate, t]
)
return (
<AppConfigContext.Provider value={{ appConfig, mutateAppConfig, patchAppConfig }}>
<AppConfigContext.Provider
value={{ appConfig: config, mutateAppConfig: mutate, patchAppConfig }}
>
{children}
</AppConfigContext.Provider>
)
}
const AppConfigContext = React.createContext<AppConfigContextType | undefined>(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

View File

@ -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<IOverrideConfig>({
swrKey: 'getOverrideConfig',
fetcher: getOverrideConfig,
ipcEvent: 'overrideConfigUpdated'
})
interface OverrideConfigContextType {
overrideConfig: IOverrideConfig | undefined
setOverrideConfig: (config: IOverrideConfig) => Promise<void>
@ -19,60 +25,59 @@ interface OverrideConfigContextType {
removeOverrideItem: (id: string) => Promise<void>
}
const OverrideConfigContext = createContext<OverrideConfigContextType | undefined>(undefined)
const OverrideConfigContext = React.createContext<OverrideConfigContextType | undefined>(undefined)
export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
return (
<Provider>
<OverrideConfigContextWrapper>{children}</OverrideConfigContextWrapper>
</Provider>
)
}
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<void>, errorKey: string) => async () => {
try {
await action()
} catch (e) {
await showError(e, t(errorKey))
} finally {
mutate()
}
},
[mutate, t]
)
const setOverrideConfig = async (config: IOverrideConfig): Promise<void> => {
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<IOverrideItem>): Promise<void> => {
try {
await add(item)
} catch (e) {
await showError(e, t('common.error.addOverrideFailed'))
} finally {
mutateOverrideConfig()
}
}
const addOverrideItem = useCallback(
(item: Partial<IOverrideItem>) => withErrorHandling(() => add(item), 'common.error.addOverrideFailed')(),
[withErrorHandling]
)
const removeOverrideItem = async (id: string): Promise<void> => {
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<void> => {
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 (
<OverrideConfigContext.Provider
value={{
overrideConfig,
overrideConfig: config,
setOverrideConfig,
mutateOverrideConfig,
mutateOverrideConfig: mutate,
addOverrideItem,
removeOverrideItem,
updateOverrideItem
@ -84,8 +89,8 @@ export const OverrideConfigProvider: React.FC<{ children: ReactNode }> = ({ 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

View File

@ -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<IProfileConfig>({
swrKey: 'getProfileConfig',
fetcher: getProfileConfig,
ipcEvent: 'profileConfigUpdated'
})
interface ProfileConfigContextType {
profileConfig: IProfileConfig | undefined
setProfileConfig: (config: IProfileConfig) => Promise<void>
@ -21,80 +27,64 @@ interface ProfileConfigContextType {
changeCurrentProfile: (id: string) => Promise<void>
}
const ProfileConfigContext = createContext<ProfileConfigContextType | undefined>(undefined)
const ProfileConfigContext = React.createContext<ProfileConfigContextType | undefined>(undefined)
export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const { t } = useTranslation()
const { data: profileConfig, mutate: mutateProfileConfig } = useSWR('getProfileConfig', () =>
getProfileConfig()
return (
<Provider>
<ProfileConfigContextWrapper>{children}</ProfileConfigContextWrapper>
</Provider>
)
const targetProfileId = React.useRef<string | null>(null)
const pendingTask = React.useRef<Promise<void> | null>(null)
}
const setProfileConfig = async (config: IProfileConfig): Promise<void> => {
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<string | null>(null)
const pendingTask = useRef<Promise<void> | null>(null)
const addProfileItem = async (item: Partial<IProfileItem>): Promise<void> => {
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<void>, 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<void> => {
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<void> => {
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<IProfileItem>) =>
withErrorHandling(() => add(item), 'common.error.addProfileFailed')(),
[withErrorHandling]
)
const changeCurrentProfile = async (id: string): Promise<void> => {
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 (
<ProfileConfigContext.Provider
value={{
profileConfig,
profileConfig: config,
setProfileConfig,
mutateProfileConfig,
mutateProfileConfig: mutate,
addProfileItem,
removeProfileItem,
updateProfileItem,
@ -149,8 +146,8 @@ export const ProfileConfigProvider: React.FC<{ children: ReactNode }> = ({ 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