import { useCallback, useEffect, useMemo, useState } from 'react' import { RefreshCw, UserPlus, Trash2, ArrowRightLeft, CheckCircle2, Database } from 'lucide-react' import { useAppStore } from '../stores/appStore' import { useChatStore } from '../stores/chatStore' import { useAnalyticsStore } from '../stores/analyticsStore' import * as configService from '../services/config' import './AccountManagementPage.scss' interface ScannedWxidOption { wxid: string modifiedTime: number nickname?: string avatarUrl?: string } interface ManagedAccountItem { wxid: string normalizedWxid: string displayName: string avatarUrl?: string modifiedTime?: number configUpdatedAt?: number hasConfig: boolean isCurrent: boolean fromScan: boolean } type AccountProfileCacheEntry = { displayName?: string avatarUrl?: string updatedAt?: number } interface DeleteUndoState { targetWxid: string deletedConfigEntries: Array<[string, configService.WxidConfig]> deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> previousCurrentWxid: string shouldRestoreAsCurrent: boolean previousDbConnected: boolean } type NoticeState = | { type: 'success' | 'error' | 'info'; text: string } | null const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1' const hiddenDeletedAccountIds = new Set() const DEFAULT_ACCOUNT_DISPLAY_NAME = '微信用户' const normalizeAccountId = (value?: string | null): string => { const trimmed = String(value || '').trim() if (!trimmed) return '' if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) return match?.[1] || trimmed } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) return suffixMatch ? suffixMatch[1] : trimmed } const resolveAccountDisplayName = ( candidates: Array, wxidCandidates: Set ): string => { for (const candidate of candidates) { if (typeof candidate !== 'string') continue if (candidate.length === 0) continue const normalized = candidate.trim().toLowerCase() if (normalized.startsWith('wxid_')) continue if (normalized && wxidCandidates.has(normalized)) continue return candidate } return DEFAULT_ACCOUNT_DISPLAY_NAME } const resolveAccountAvatarText = (displayName?: string): string => { if (typeof displayName !== 'string' || displayName.length === 0) return '微' const visible = displayName.trim() return (visible && [...visible][0]) || '微' } const readAccountProfilesCache = (): Record => { try { const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY) if (!raw) return {} const parsed = JSON.parse(raw) return parsed && typeof parsed === 'object' ? parsed as Record : {} } catch { return {} } } function AccountManagementPage() { const isDbConnected = useAppStore(state => state.isDbConnected) const setDbConnected = useAppStore(state => state.setDbConnected) const resetChatStore = useChatStore(state => state.reset) const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache) const [dbPath, setDbPath] = useState('') const [currentWxid, setCurrentWxid] = useState('') const [accounts, setAccounts] = useState([]) const [isLoading, setIsLoading] = useState(false) const [workingWxid, setWorkingWxid] = useState('') const [notice, setNotice] = useState(null) const [deleteUndoState, setDeleteUndoState] = useState(null) const loadAccounts = useCallback(async () => { setIsLoading(true) try { const [path, rawCurrentWxid, wxidConfigs] = await Promise.all([ configService.getDbPath(), configService.getMyWxid(), configService.getWxidConfigs() ]) const nextDbPath = String(path || '').trim() const nextCurrentWxid = String(rawCurrentWxid || '').trim() const normalizedCurrent = normalizeAccountId(nextCurrentWxid) || nextCurrentWxid setDbPath(nextDbPath) setCurrentWxid(nextCurrentWxid) let scannedWxids: ScannedWxidOption[] = [] if (nextDbPath) { try { const scanned = await window.electronAPI.dbPath.scanWxids(nextDbPath) scannedWxids = Array.isArray(scanned) ? scanned as ScannedWxidOption[] : [] } catch { scannedWxids = [] } } const accountProfileCache = readAccountProfilesCache() const configEntries = Object.entries(wxidConfigs || {}) const configByNormalized = new Map() for (const [wxid, cfg] of configEntries) { const normalized = normalizeAccountId(wxid) || wxid if (!normalized) continue const previous = configByNormalized.get(normalized) if (!previous || Number(cfg?.updatedAt || 0) > Number(previous.value?.updatedAt || 0)) { configByNormalized.set(normalized, { key: wxid, value: cfg || {} }) } } const merged = new Map() for (const scanned of scannedWxids) { const normalized = normalizeAccountId(scanned.wxid) || scanned.wxid if (!normalized) continue const cached = accountProfileCache[scanned.wxid] || accountProfileCache[normalized] const matchedConfig = configByNormalized.get(normalized) const wxidCandidates = new Set([ String(scanned.wxid || '').trim().toLowerCase(), String(normalized || '').trim().toLowerCase() ].filter(Boolean)) const displayName = resolveAccountDisplayName( [scanned.nickname, cached?.displayName], wxidCandidates ) merged.set(normalized, { wxid: scanned.wxid, normalizedWxid: normalized, displayName, avatarUrl: scanned.avatarUrl || cached?.avatarUrl, modifiedTime: Number(scanned.modifiedTime || 0), configUpdatedAt: Number(matchedConfig?.value?.updatedAt || 0), hasConfig: Boolean(matchedConfig), isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent, fromScan: true }) } for (const [normalized, matchedConfig] of configByNormalized.entries()) { if (merged.has(normalized)) continue const wxid = matchedConfig.key const cached = accountProfileCache[wxid] || accountProfileCache[normalized] const wxidCandidates = new Set([ String(wxid || '').trim().toLowerCase(), String(normalized || '').trim().toLowerCase() ].filter(Boolean)) const displayName = resolveAccountDisplayName( [cached?.displayName], wxidCandidates ) merged.set(normalized, { wxid, normalizedWxid: normalized, displayName, avatarUrl: cached?.avatarUrl, modifiedTime: 0, configUpdatedAt: Number(matchedConfig.value?.updatedAt || 0), hasConfig: true, isCurrent: Boolean(normalizedCurrent) && normalized === normalizedCurrent, fromScan: false }) } // 被“删除配置”操作移除的账号,在当前会话中从列表隐藏; // 若后续再次生成配置,则自动恢复展示。 for (const [normalized, item] of Array.from(merged.entries())) { if (!hiddenDeletedAccountIds.has(normalized)) continue if (item.hasConfig) { hiddenDeletedAccountIds.delete(normalized) continue } merged.delete(normalized) } const nextAccounts = Array.from(merged.values()).sort((a, b) => { if (a.isCurrent && !b.isCurrent) return -1 if (!a.isCurrent && b.isCurrent) return 1 const scanDiff = Number(b.modifiedTime || 0) - Number(a.modifiedTime || 0) if (scanDiff !== 0) return scanDiff return Number(b.configUpdatedAt || 0) - Number(a.configUpdatedAt || 0) }) setAccounts(nextAccounts) } catch (error) { console.error('加载账号列表失败:', error) setNotice({ type: 'error', text: '加载账号列表失败,请稍后重试' }) setAccounts([]) } finally { setIsLoading(false) } }, []) useEffect(() => { void loadAccounts() const onWxidChanged = () => { void loadAccounts() } const onWindowFocus = () => { void loadAccounts() } const onVisibilityChange = () => { if (document.visibilityState === 'visible') { void loadAccounts() } } window.addEventListener('wxid-changed', onWxidChanged as EventListener) window.addEventListener('focus', onWindowFocus) document.addEventListener('visibilitychange', onVisibilityChange) return () => { window.removeEventListener('wxid-changed', onWxidChanged as EventListener) window.removeEventListener('focus', onWindowFocus) document.removeEventListener('visibilitychange', onVisibilityChange) } }, [loadAccounts]) const clearRuntimeCacheState = useCallback(async () => { if (isDbConnected) { await window.electronAPI.chat.close() } window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY) clearAnalyticsStoreCache() resetChatStore() }, [clearAnalyticsStoreCache, isDbConnected, resetChatStore]) const applyWxidConfig = useCallback(async (wxid: string, wxidConfig: configService.WxidConfig | null) => { await configService.setMyWxid(wxid) await configService.setDecryptKey(wxidConfig?.decryptKey || '') await configService.setImageXorKey(typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : 0) await configService.setImageAesKey(wxidConfig?.imageAesKey || '') }, []) const handleSwitchAccount = useCallback(async (wxid: string) => { if (!wxid || workingWxid) return const targetNormalized = normalizeAccountId(wxid) || wxid const currentNormalized = normalizeAccountId(currentWxid) || currentWxid if (targetNormalized && currentNormalized && targetNormalized === currentNormalized) return setWorkingWxid(wxid) setNotice(null) setDeleteUndoState(null) try { const allConfigs = await configService.getWxidConfigs() const configEntries = Object.entries(allConfigs || {}) const matched = configEntries.find(([key]) => { const normalized = normalizeAccountId(key) || key return key === wxid || normalized === targetNormalized }) const targetConfig = matched?.[1] || null await applyWxidConfig(wxid, targetConfig) await clearRuntimeCacheState() window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid } })) setNotice({ type: 'success', text: `已切换到账号「${wxid}」` }) await loadAccounts() } catch (error) { console.error('切换账号失败:', error) setNotice({ type: 'error', text: '切换账号失败,请稍后重试' }) } finally { setWorkingWxid('') } }, [applyWxidConfig, clearRuntimeCacheState, currentWxid, loadAccounts, workingWxid]) const handleAddAccount = useCallback(async () => { if (workingWxid) return setNotice(null) setDeleteUndoState(null) try { await window.electronAPI.window.openOnboardingWindow({ mode: 'add-account' }) await loadAccounts() const latestWxid = String(await configService.getMyWxid() || '').trim() window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: latestWxid } })) } catch (error) { console.error('打开添加账号引导失败:', error) setNotice({ type: 'error', text: '打开添加账号引导失败,请稍后重试' }) } }, [loadAccounts, workingWxid]) const handleDeleteAccountConfig = useCallback(async (targetWxid: string) => { if (!targetWxid || workingWxid) return const normalizedTarget = normalizeAccountId(targetWxid) || targetWxid setWorkingWxid(targetWxid) setNotice(null) setDeleteUndoState(null) try { const allConfigs = await configService.getWxidConfigs() const nextConfigs: Record = { ...allConfigs } const matchedKeys = Object.keys(nextConfigs).filter((key) => { const normalized = normalizeAccountId(key) || key return key === targetWxid || normalized === normalizedTarget }) if (matchedKeys.length === 0) { setNotice({ type: 'info', text: `账号「${targetWxid}」暂无可删除配置` }) return } const deletedConfigEntries: Array<[string, configService.WxidConfig]> = matchedKeys.map((key) => [key, nextConfigs[key] || {}]) for (const key of matchedKeys) { delete nextConfigs[key] } await configService.setWxidConfigs(nextConfigs) const accountProfileCache = readAccountProfilesCache() const deletedProfileEntries: Array<[string, AccountProfileCacheEntry]> = [] for (const key of Object.keys(accountProfileCache)) { const normalized = normalizeAccountId(key) || key if (key === targetWxid || normalized === normalizedTarget) { deletedProfileEntries.push([key, accountProfileCache[key]]) delete accountProfileCache[key] } } window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache)) const currentNormalized = normalizeAccountId(currentWxid) || currentWxid const isDeletingCurrent = Boolean(currentNormalized && currentNormalized === normalizedTarget) const undoPayload: DeleteUndoState = { targetWxid, deletedConfigEntries, deletedProfileEntries, previousCurrentWxid: currentWxid, shouldRestoreAsCurrent: isDeletingCurrent, previousDbConnected: isDbConnected } if (isDeletingCurrent) { await clearRuntimeCacheState() const remainingEntries = Object.entries(nextConfigs) .filter(([wxid]) => Boolean(String(wxid || '').trim())) .sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0)) if (remainingEntries.length > 0) { const [nextWxid, nextConfig] = remainingEntries[0] await applyWxidConfig(nextWxid, nextConfig || null) window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: nextWxid } })) hiddenDeletedAccountIds.add(normalizedTarget) setDeleteUndoState(undoPayload) setNotice({ type: 'success', text: `已删除「${targetWxid}」配置,并切换到「${nextWxid}」` }) await loadAccounts() return } await configService.setMyWxid('') await configService.setDecryptKey('') await configService.setImageXorKey(0) await configService.setImageAesKey('') setDbConnected(false) window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: '' } })) hiddenDeletedAccountIds.add(normalizedTarget) setDeleteUndoState(undoPayload) setNotice({ type: 'info', text: `已删除「${targetWxid}」配置,当前无可用账号配置,可撤回或添加账号` }) await loadAccounts() return } hiddenDeletedAccountIds.add(normalizedTarget) setDeleteUndoState(undoPayload) setNotice({ type: 'success', text: `已删除账号「${targetWxid}」配置` }) await loadAccounts() } catch (error) { console.error('删除账号配置失败:', error) setNotice({ type: 'error', text: '删除账号配置失败,请稍后重试' }) } finally { setWorkingWxid('') } }, [applyWxidConfig, clearRuntimeCacheState, currentWxid, isDbConnected, loadAccounts, setDbConnected, workingWxid]) const handleUndoDelete = useCallback(async () => { if (!deleteUndoState || workingWxid) return setWorkingWxid(`undo:${deleteUndoState.targetWxid}`) setNotice(null) try { const currentConfigs = await configService.getWxidConfigs() const restoredConfigs: Record = { ...currentConfigs } for (const [key, configValue] of deleteUndoState.deletedConfigEntries) { restoredConfigs[key] = configValue || {} } await configService.setWxidConfigs(restoredConfigs) hiddenDeletedAccountIds.delete(normalizeAccountId(deleteUndoState.targetWxid) || deleteUndoState.targetWxid) const accountProfileCache = readAccountProfilesCache() for (const [key, profile] of deleteUndoState.deletedProfileEntries) { accountProfileCache[key] = profile } window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountProfileCache)) if (deleteUndoState.shouldRestoreAsCurrent && deleteUndoState.previousCurrentWxid) { const previousNormalized = normalizeAccountId(deleteUndoState.previousCurrentWxid) || deleteUndoState.previousCurrentWxid const restoreConfigEntry = Object.entries(restoredConfigs) .filter(([key]) => { const normalized = normalizeAccountId(key) || key return key === deleteUndoState.previousCurrentWxid || normalized === previousNormalized }) .sort((a, b) => Number(b[1]?.updatedAt || 0) - Number(a[1]?.updatedAt || 0))[0] const restoreConfig = restoreConfigEntry?.[1] || null await clearRuntimeCacheState() await applyWxidConfig(deleteUndoState.previousCurrentWxid, restoreConfig) if (deleteUndoState.previousDbConnected) { setDbConnected(true, dbPath || undefined) } window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: deleteUndoState.previousCurrentWxid } })) } setNotice({ type: 'success', text: `已撤回删除,账号「${deleteUndoState.targetWxid}」配置已恢复` }) setDeleteUndoState(null) await loadAccounts() } catch (error) { console.error('撤回删除失败:', error) setNotice({ type: 'error', text: '撤回删除失败,请稍后重试' }) } finally { setWorkingWxid('') } }, [applyWxidConfig, clearRuntimeCacheState, dbPath, deleteUndoState, loadAccounts, setDbConnected, workingWxid]) const currentAccountLabel = useMemo(() => { if (!currentWxid) return '未设置' return currentWxid }, [currentWxid]) const formatTime = (value?: number): string => { const ts = Number(value || 0) if (!ts) return '未知' const date = new Date(ts) if (Number.isNaN(date.getTime())) return '未知' const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') const hour = String(date.getHours()).padStart(2, '0') const minute = String(date.getMinutes()).padStart(2, '0') return `${year}-${month}-${day} ${hour}:${minute}` } return (

账号管理

统一管理切换账号、添加账号、删除账号配置。

数据库目录 {dbPath || '未配置'}
当前账号 {currentAccountLabel}
账号数量 {accounts.length}
{notice && (
{notice.text} {deleteUndoState && (notice.type === 'success' || notice.type === 'info') && ( )}
)} {accounts.length === 0 ? (
未发现可管理账号,请先添加账号或检查数据库目录。
) : (
{accounts.map((account) => (
{account.avatarUrl ? : {resolveAccountAvatarText(account.displayName)}}

{account.displayName}

{account.isCurrent && ( 当前 )} {account.hasConfig ? ( 已保存配置 ) : ( 未保存配置 )}
wxid: {account.wxid}
最近数据更新时间: {formatTime(account.modifiedTime)} · 配置更新时间: {formatTime(account.configUpdatedAt)} {!account.fromScan && (仅配置记录)}
))}
)}
) } export default AccountManagementPage