feat(desktop): add global channels store with zustand

- Create ChannelsStore for global channel state management
- Refactor useChannels hook to use the store
- Initialize channels store at app startup
- Share state between onboarding and channels page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-02-11 17:43:23 +08:00
parent 07b8a014aa
commit 5ae86feb2b
3 changed files with 175 additions and 97 deletions

View file

@ -10,6 +10,8 @@ import ChannelsPage from './pages/channels'
import CronsPage from './pages/crons'
import OnboardingPage from './pages/onboarding'
import { useOnboardingStore } from './stores/onboarding'
import { useProviderStore } from './stores/provider'
import { useChannelsStore } from './stores/channels'
function OnboardingGuard({ children }: { children: React.ReactNode }) {
const completed = useOnboardingStore((s) => s.completed)
@ -47,6 +49,9 @@ const router = createHashRouter([
export default function App() {
useEffect(() => {
useOnboardingStore.getState().initForceFlag()
// Prefetch global data at app startup
useProviderStore.getState().fetch()
useChannelsStore.getState().fetch()
}, [])
return (

View file

@ -1,12 +1,10 @@
/**
* Hook for managing channel accounts (Telegram, Discord, etc.) in the Desktop App.
*
* Provides state and actions for the Channels settings page:
* - List channel account states (running / stopped / error)
* - Read channel config (tokens)
* - Save / remove tokens with immediate start/stop
* Uses the global ChannelsStore for state management.
* Data is fetched once at app startup and shared across all components.
*/
import { useState, useEffect, useCallback } from 'react'
import { useChannelsStore } from '../stores/channels'
export interface UseChannelsReturn {
/** Runtime states of all channel accounts */
@ -24,102 +22,23 @@ export interface UseChannelsReturn {
/** Remove a bot token — stops the bot and removes from file */
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
/** Stop a channel account without removing config */
stopChannel: (channelId: string, accountId: string) => Promise<void>
stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
/** Start a channel account from saved config */
startChannel: (channelId: string, accountId: string) => Promise<void>
startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
}
export function useChannels(): UseChannelsReturn {
const [states, setStates] = useState<ChannelAccountStateInfo[]>([])
const [config, setConfig] = useState<Record<string, Record<string, Record<string, unknown>> | undefined>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
setStates(stateList)
setConfig(channelConfig)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
console.error('[useChannels] Failed to load:', message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
refresh()
}, [refresh])
const saveToken = useCallback(async (channelId: string, accountId: string, token: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.saveToken(channelId, accountId, token)
if (!result.ok) {
setError(result.error ?? 'Failed to save token')
}
// Refresh to pick up new state
await refresh()
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const removeToken = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.removeToken(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to remove token')
}
await refresh()
return result
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
return { ok: false, error: message }
}
}, [refresh])
const stopChannel = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.stop(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to stop channel')
}
await refresh()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
}
}, [refresh])
const startChannel = useCallback(async (channelId: string, accountId: string) => {
setError(null)
try {
const result = await window.electronAPI.channels.start(channelId, accountId)
if (!result.ok) {
setError(result.error ?? 'Failed to start channel')
}
await refresh()
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
setError(message)
}
}, [refresh])
const {
states,
config,
loading,
error,
refresh,
saveToken,
removeToken,
stopChannel,
startChannel,
} = useChannelsStore()
return {
states,

View file

@ -0,0 +1,154 @@
import { create } from 'zustand'
interface ChannelsStore {
// State
states: ChannelAccountStateInfo[]
config: Record<string, Record<string, Record<string, unknown>> | undefined>
loading: boolean
error: string | null
initialized: boolean
// Actions
fetch: () => Promise<void>
refresh: () => Promise<void>
saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }>
removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
stopChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
startChannel: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }>
}
export const useChannelsStore = create<ChannelsStore>()((set, get) => ({
states: [],
config: {},
loading: false,
error: null,
initialized: false,
fetch: async () => {
// Skip if already initialized
if (get().initialized) return
set({ loading: true, error: null })
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
set({
states: stateList,
config: channelConfig,
initialized: true,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[ChannelsStore] Failed to load:', message)
} finally {
set({ loading: false })
}
},
refresh: async () => {
set({ loading: true, error: null })
try {
const [stateList, channelConfig] = await Promise.all([
window.electronAPI.channels.listStates(),
window.electronAPI.channels.getConfig(),
])
set({
states: stateList,
config: channelConfig,
})
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
console.error('[ChannelsStore] Failed to refresh:', message)
} finally {
set({ loading: false })
}
},
saveToken: async (channelId: string, accountId: string, token: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.saveToken(channelId, accountId, token)
if (result.ok) {
await get().refresh()
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to save token' })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return { ok: false, error: message }
}
},
removeToken: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.removeToken(channelId, accountId)
if (result.ok) {
await get().refresh()
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to remove token' })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return { ok: false, error: message }
}
},
stopChannel: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.stop(channelId, accountId)
if (result.ok) {
await get().refresh()
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to stop channel' })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return { ok: false, error: message }
}
},
startChannel: async (channelId: string, accountId: string) => {
set({ error: null })
try {
const result = await window.electronAPI.channels.start(channelId, accountId)
if (result.ok) {
await get().refresh()
return { ok: true }
} else {
set({ error: result.error ?? 'Failed to start channel' })
return { ok: false, error: result.error }
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set({ error: message })
return { ok: false, error: message }
}
},
}))