Files
WeFlow/src/pages/AccountManagementPage.tsx
2026-04-13 19:32:54 +08:00

575 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>()
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<unknown>,
wxidCandidates: Set<string>
): 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<string, AccountProfileCacheEntry> => {
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<string, AccountProfileCacheEntry> : {}
} 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<ManagedAccountItem[]>([])
const [isLoading, setIsLoading] = useState(false)
const [workingWxid, setWorkingWxid] = useState('')
const [notice, setNotice] = useState<NoticeState>(null)
const [deleteUndoState, setDeleteUndoState] = useState<DeleteUndoState | null>(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<string, { key: string; value: configService.WxidConfig }>()
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<string, ManagedAccountItem>()
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>([
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>([
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<string, configService.WxidConfig> = { ...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<string, configService.WxidConfig> = { ...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 (
<div className="account-management-page">
<header className="account-management-header">
<div>
<h2></h2>
<p></p>
</div>
<div className="account-management-actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadAccounts()} disabled={isLoading || Boolean(workingWxid)}>
<RefreshCw size={16} /> {isLoading ? '刷新中...' : '刷新'}
</button>
<button type="button" className="btn btn-primary" onClick={handleAddAccount} disabled={Boolean(workingWxid)}>
<UserPlus size={16} />
</button>
</div>
</header>
<section className="account-management-summary">
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{dbPath || '未配置'}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{currentAccountLabel}</span>
</div>
<div className="summary-item">
<span className="summary-label"></span>
<span className="summary-value">{accounts.length}</span>
</div>
</section>
{notice && (
<div className={`account-notice ${notice.type}`}>
<span>{notice.text}</span>
{deleteUndoState && (notice.type === 'success' || notice.type === 'info') && (
<button
type="button"
className="notice-action"
onClick={() => void handleUndoDelete()}
disabled={Boolean(workingWxid)}
>
</button>
)}
</div>
)}
{accounts.length === 0 ? (
<div className="account-empty">
<Database size={20} />
<span></span>
</div>
) : (
<div className="account-list">
{accounts.map((account) => (
<article key={account.normalizedWxid} className={`account-card ${account.isCurrent ? 'is-current' : ''}`}>
<div className="account-avatar">
{account.avatarUrl ? <img src={account.avatarUrl} alt="" /> : <span>{resolveAccountAvatarText(account.displayName)}</span>}
</div>
<div className="account-main">
<div className="account-title-row">
<h3>{account.displayName}</h3>
{account.isCurrent && (
<span className="account-badge current">
<CheckCircle2 size={12} />
</span>
)}
{account.hasConfig ? (
<span className="account-badge ok"></span>
) : (
<span className="account-badge warn"></span>
)}
</div>
<div className="account-meta">wxid: {account.wxid}</div>
<div className="account-meta">
: {formatTime(account.modifiedTime)} · : {formatTime(account.configUpdatedAt)}
{!account.fromScan && <span className="meta-tip"></span>}
</div>
</div>
<div className="account-card-actions">
<button
type="button"
className="btn btn-secondary"
onClick={() => void handleSwitchAccount(account.wxid)}
disabled={Boolean(workingWxid) || account.isCurrent || !account.hasConfig || !account.fromScan}
>
<ArrowRightLeft size={14} /> {account.isCurrent ? '当前账号' : (!account.hasConfig ? '无配置' : (account.fromScan ? '切换' : '无数据'))}
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => void handleDeleteAccountConfig(account.wxid)}
disabled={Boolean(workingWxid) || !account.hasConfig}
>
<Trash2 size={14} />
</button>
</div>
</article>
))}
</div>
)}
<footer className="account-management-footer">
WeFlow
</footer>
</div>
)
}
export default AccountManagementPage