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