mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
587 lines
21 KiB
TypeScript
587 lines
21 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
|
|
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, RefreshCw } 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 { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
|
|
import { UserRound } from 'lucide-react'
|
|
|
|
import './Sidebar.scss'
|
|
|
|
interface SidebarUserProfile {
|
|
wxid: string
|
|
displayName: string
|
|
alias?: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
|
|
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
|
|
|
|
interface SidebarUserProfileCache extends SidebarUserProfile {
|
|
updatedAt: number
|
|
}
|
|
|
|
interface AccountProfilesCache {
|
|
[wxid: string]: {
|
|
displayName: string
|
|
avatarUrl?: string
|
|
alias?: string
|
|
updatedAt: number
|
|
}
|
|
}
|
|
|
|
interface WxidOption {
|
|
wxid: string
|
|
modifiedTime: number
|
|
nickname?: string
|
|
displayName?: string
|
|
avatarUrl?: string
|
|
}
|
|
|
|
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
|
|
try {
|
|
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
|
if (!raw) return null
|
|
const parsed = JSON.parse(raw) as SidebarUserProfileCache
|
|
if (!parsed || typeof parsed !== 'object') return null
|
|
if (!parsed.wxid || !parsed.displayName) return null
|
|
return {
|
|
wxid: parsed.wxid,
|
|
displayName: parsed.displayName,
|
|
alias: parsed.alias,
|
|
avatarUrl: parsed.avatarUrl
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
|
|
if (!profile.wxid || !profile.displayName) return
|
|
try {
|
|
const payload: SidebarUserProfileCache = {
|
|
...profile,
|
|
updatedAt: Date.now()
|
|
}
|
|
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
|
|
|
|
// 同时写入账号缓存池
|
|
const accountsCache = readAccountProfilesCache()
|
|
accountsCache[profile.wxid] = {
|
|
displayName: profile.displayName,
|
|
avatarUrl: profile.avatarUrl,
|
|
alias: profile.alias,
|
|
updatedAt: Date.now()
|
|
}
|
|
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
|
|
} catch {
|
|
// 忽略本地缓存失败,不影响主流程
|
|
}
|
|
}
|
|
|
|
const readAccountProfilesCache = (): AccountProfilesCache => {
|
|
try {
|
|
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
|
|
if (!raw) return {}
|
|
const parsed = JSON.parse(raw)
|
|
return typeof parsed === 'object' && parsed ? parsed : {}
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
interface SidebarProps {
|
|
collapsed: boolean
|
|
}
|
|
|
|
function Sidebar({ collapsed }: SidebarProps) {
|
|
const location = useLocation()
|
|
const navigate = useNavigate()
|
|
const [authEnabled, setAuthEnabled] = useState(false)
|
|
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
|
|
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
|
|
wxid: '',
|
|
displayName: '未识别用户'
|
|
})
|
|
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
|
const [showSwitchAccountDialog, setShowSwitchAccountDialog] = useState(false)
|
|
const [wxidOptions, setWxidOptions] = useState<WxidOption[]>([])
|
|
const [isSwitchingAccount, setIsSwitchingAccount] = useState(false)
|
|
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
|
|
const setLocked = useAppStore(state => state.setLocked)
|
|
const isDbConnected = useAppStore(state => state.isDbConnected)
|
|
const resetChatStore = useChatStore(state => state.reset)
|
|
const clearAnalyticsStoreCache = useAnalyticsStore(state => state.clearCache)
|
|
|
|
useEffect(() => {
|
|
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (!isAccountMenuOpen) return
|
|
const target = event.target as Node | null
|
|
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
|
|
setIsAccountMenuOpen(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [isAccountMenuOpen])
|
|
|
|
useEffect(() => {
|
|
const unsubscribe = onExportSessionStatus((payload) => {
|
|
const countFromPayload = typeof payload?.activeTaskCount === 'number'
|
|
? payload.activeTaskCount
|
|
: Array.isArray(payload?.inProgressSessionIds)
|
|
? payload.inProgressSessionIds.length
|
|
: 0
|
|
const normalized = Math.max(0, Math.floor(countFromPayload))
|
|
setActiveExportTaskCount(normalized)
|
|
})
|
|
|
|
requestExportSessionStatus()
|
|
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
|
|
|
|
return () => {
|
|
unsubscribe()
|
|
window.clearTimeout(timer)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const loadCurrentUser = async () => {
|
|
const patchUserProfile = (patch: Partial<SidebarUserProfile>, expectedWxid?: string) => {
|
|
setUserProfile(prev => {
|
|
if (expectedWxid && prev.wxid && prev.wxid !== expectedWxid) {
|
|
return prev
|
|
}
|
|
const next: SidebarUserProfile = {
|
|
...prev,
|
|
...patch
|
|
}
|
|
if (!next.displayName) {
|
|
next.displayName = next.wxid || '未识别用户'
|
|
}
|
|
writeSidebarUserProfileCache(next)
|
|
return next
|
|
})
|
|
}
|
|
|
|
try {
|
|
const wxid = await configService.getMyWxid()
|
|
const resolvedWxidRaw = String(wxid || '').trim()
|
|
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
|
|
const resolvedWxid = cleanedWxid || resolvedWxidRaw
|
|
|
|
if (!resolvedWxidRaw && !resolvedWxid) return
|
|
|
|
const wxidCandidates = new Set<string>([
|
|
resolvedWxidRaw.toLowerCase(),
|
|
resolvedWxid.trim().toLowerCase(),
|
|
cleanedWxid.trim().toLowerCase()
|
|
].filter(Boolean))
|
|
|
|
const normalizeName = (value?: string | null): string | undefined => {
|
|
if (!value) return undefined
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return undefined
|
|
const lowered = trimmed.toLowerCase()
|
|
if (lowered === 'self') return undefined
|
|
if (lowered.startsWith('wxid_')) return undefined
|
|
if (wxidCandidates.has(lowered)) return undefined
|
|
return trimmed
|
|
}
|
|
|
|
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
|
|
for (const candidate of candidates) {
|
|
const normalized = normalizeName(candidate)
|
|
if (normalized) return normalized
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
// 并行获取名称和头像
|
|
const [contactResult, avatarResult] = await Promise.allSettled([
|
|
(async () => {
|
|
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
|
|
for (const candidate of candidates) {
|
|
const contact = await window.electronAPI.chat.getContact(candidate)
|
|
if (contact?.remark || contact?.nickName || contact?.alias) {
|
|
return contact
|
|
}
|
|
}
|
|
return null
|
|
})(),
|
|
window.electronAPI.chat.getMyAvatarUrl()
|
|
])
|
|
|
|
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
|
|
const displayName = pickFirstValidName(
|
|
myContact?.remark,
|
|
myContact?.nickName,
|
|
myContact?.alias
|
|
) || resolvedWxid || '未识别用户'
|
|
|
|
patchUserProfile({
|
|
wxid: resolvedWxid,
|
|
displayName,
|
|
alias: myContact?.alias,
|
|
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
|
|
? avatarResult.value.avatarUrl
|
|
: undefined
|
|
})
|
|
} catch (error) {
|
|
console.error('加载侧边栏用户信息失败:', error)
|
|
}
|
|
}
|
|
|
|
const cachedProfile = readSidebarUserProfileCache()
|
|
if (cachedProfile) {
|
|
setUserProfile(cachedProfile)
|
|
}
|
|
|
|
void loadCurrentUser()
|
|
const onWxidChanged = () => { void loadCurrentUser() }
|
|
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
|
|
return () => window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
|
|
}, [])
|
|
|
|
const getAvatarLetter = (name: string): string => {
|
|
if (!name) return '?'
|
|
return [...name][0] || '?'
|
|
}
|
|
|
|
const openSwitchAccountDialog = async () => {
|
|
setIsAccountMenuOpen(false)
|
|
if (!isDbConnected) {
|
|
window.alert('数据库未连接,无法切换账号')
|
|
return
|
|
}
|
|
const dbPath = await configService.getDbPath()
|
|
if (!dbPath) {
|
|
window.alert('请先在设置中配置数据库路径')
|
|
return
|
|
}
|
|
try {
|
|
const wxids = await window.electronAPI.dbPath.scanWxids(dbPath)
|
|
const accountsCache = readAccountProfilesCache()
|
|
console.log('[切换账号] 账号缓存:', accountsCache)
|
|
|
|
const enrichedWxids = wxids.map((option: WxidOption) => {
|
|
const normalizedWxid = normalizeAccountId(option.wxid)
|
|
const cached = accountsCache[option.wxid] || accountsCache[normalizedWxid]
|
|
|
|
let displayName = option.nickname || option.wxid
|
|
let avatarUrl = option.avatarUrl
|
|
|
|
if (option.wxid === userProfile.wxid || normalizedWxid === userProfile.wxid) {
|
|
displayName = userProfile.displayName || displayName
|
|
avatarUrl = userProfile.avatarUrl || avatarUrl
|
|
}
|
|
|
|
else if (cached) {
|
|
displayName = cached.displayName || displayName
|
|
avatarUrl = cached.avatarUrl || avatarUrl
|
|
}
|
|
|
|
return {
|
|
...option,
|
|
displayName,
|
|
avatarUrl
|
|
}
|
|
})
|
|
|
|
setWxidOptions(enrichedWxids)
|
|
setShowSwitchAccountDialog(true)
|
|
} catch (error) {
|
|
console.error('扫描账号失败:', error)
|
|
window.alert('扫描账号失败,请稍后重试')
|
|
}
|
|
}
|
|
|
|
const handleSwitchAccount = async (selectedWxid: string) => {
|
|
if (!selectedWxid || isSwitchingAccount) return
|
|
setIsSwitchingAccount(true)
|
|
try {
|
|
console.log('[切换账号] 开始切换到:', selectedWxid)
|
|
const currentWxid = userProfile.wxid
|
|
if (currentWxid === selectedWxid) {
|
|
console.log('[切换账号] 已经是当前账号,跳过')
|
|
setShowSwitchAccountDialog(false)
|
|
setIsSwitchingAccount(false)
|
|
return
|
|
}
|
|
|
|
console.log('[切换账号] 设置新 wxid')
|
|
await configService.setMyWxid(selectedWxid)
|
|
|
|
console.log('[切换账号] 获取账号配置')
|
|
const wxidConfig = await configService.getWxidConfig(selectedWxid)
|
|
console.log('[切换账号] 配置内容:', wxidConfig)
|
|
if (wxidConfig?.decryptKey) {
|
|
console.log('[切换账号] 设置 decryptKey')
|
|
await configService.setDecryptKey(wxidConfig.decryptKey)
|
|
}
|
|
if (typeof wxidConfig?.imageXorKey === 'number') {
|
|
console.log('[切换账号] 设置 imageXorKey:', wxidConfig.imageXorKey)
|
|
await configService.setImageXorKey(wxidConfig.imageXorKey)
|
|
}
|
|
if (wxidConfig?.imageAesKey) {
|
|
console.log('[切换账号] 设置 imageAesKey')
|
|
await configService.setImageAesKey(wxidConfig.imageAesKey)
|
|
}
|
|
|
|
console.log('[切换账号] 检查数据库连接状态')
|
|
console.log('[切换账号] 数据库连接状态:', isDbConnected)
|
|
if (isDbConnected) {
|
|
console.log('[切换账号] 关闭数据库连接')
|
|
await window.electronAPI.chat.close()
|
|
}
|
|
|
|
console.log('[切换账号] 清除缓存')
|
|
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
|
|
clearAnalyticsStoreCache()
|
|
resetChatStore()
|
|
|
|
console.log('[切换账号] 触发 wxid-changed 事件')
|
|
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: selectedWxid } }))
|
|
|
|
console.log('[切换账号] 切换成功')
|
|
setShowSwitchAccountDialog(false)
|
|
} catch (error) {
|
|
console.error('[切换账号] 失败:', error)
|
|
window.alert('切换账号失败,请稍后重试')
|
|
} finally {
|
|
setIsSwitchingAccount(false)
|
|
}
|
|
}
|
|
|
|
const openSettingsFromAccountMenu = () => {
|
|
setIsAccountMenuOpen(false)
|
|
navigate('/settings', {
|
|
state: {
|
|
backgroundLocation: location
|
|
}
|
|
})
|
|
}
|
|
|
|
const isActive = (path: string) => {
|
|
return location.pathname === path || location.pathname.startsWith(`${path}/`)
|
|
}
|
|
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
|
|
|
|
return (
|
|
<>
|
|
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
|
|
<nav className="nav-menu">
|
|
{/* 首页 */}
|
|
<NavLink
|
|
to="/home"
|
|
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
|
|
title={collapsed ? '首页' : undefined}
|
|
>
|
|
<span className="nav-icon"><Home size={20} /></span>
|
|
<span className="nav-label">首页</span>
|
|
</NavLink>
|
|
|
|
{/* 聊天 */}
|
|
<NavLink
|
|
to="/chat"
|
|
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
|
|
title={collapsed ? '聊天' : undefined}
|
|
>
|
|
<span className="nav-icon"><MessageSquare size={20} /></span>
|
|
<span className="nav-label">聊天</span>
|
|
</NavLink>
|
|
|
|
{/* 朋友圈 */}
|
|
<NavLink
|
|
to="/sns"
|
|
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
|
|
title={collapsed ? '朋友圈' : undefined}
|
|
>
|
|
<span className="nav-icon"><Aperture size={20} /></span>
|
|
<span className="nav-label">朋友圈</span>
|
|
</NavLink>
|
|
|
|
{/* 通讯录 */}
|
|
<NavLink
|
|
to="/contacts"
|
|
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
|
|
title={collapsed ? '通讯录' : undefined}
|
|
>
|
|
<span className="nav-icon"><UserCircle size={20} /></span>
|
|
<span className="nav-label">通讯录</span>
|
|
</NavLink>
|
|
|
|
{/* 聊天分析 */}
|
|
<NavLink
|
|
to="/analytics"
|
|
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
|
|
title={collapsed ? '聊天分析' : undefined}
|
|
>
|
|
<span className="nav-icon"><BarChart3 size={20} /></span>
|
|
<span className="nav-label">聊天分析</span>
|
|
</NavLink>
|
|
|
|
{/* 年度报告 */}
|
|
<NavLink
|
|
to="/annual-report"
|
|
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
|
|
title={collapsed ? '年度报告' : undefined}
|
|
>
|
|
<span className="nav-icon"><FileText size={20} /></span>
|
|
<span className="nav-label">年度报告</span>
|
|
</NavLink>
|
|
|
|
{/* 导出 */}
|
|
<NavLink
|
|
to="/export"
|
|
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
|
|
title={collapsed ? '导出' : undefined}
|
|
>
|
|
<span className="nav-icon nav-icon-with-badge">
|
|
<Download size={20} />
|
|
{collapsed && activeExportTaskCount > 0 && (
|
|
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
|
|
)}
|
|
</span>
|
|
<span className="nav-label">导出</span>
|
|
{!collapsed && activeExportTaskCount > 0 && (
|
|
<span className="nav-badge">{exportTaskBadge}</span>
|
|
)}
|
|
</NavLink>
|
|
|
|
|
|
</nav>
|
|
|
|
<div className="sidebar-footer">
|
|
<button
|
|
className="nav-item"
|
|
onClick={() => {
|
|
if (authEnabled) {
|
|
setLocked(true)
|
|
return
|
|
}
|
|
navigate('/settings', {
|
|
state: {
|
|
initialTab: 'security',
|
|
backgroundLocation: location
|
|
}
|
|
})
|
|
}}
|
|
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
|
|
>
|
|
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
|
|
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
|
|
</button>
|
|
|
|
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
|
|
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
|
|
<button
|
|
className="sidebar-user-menu-item"
|
|
onClick={openSwitchAccountDialog}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<RefreshCw size={14} />
|
|
<span>切换账号</span>
|
|
</button>
|
|
<button
|
|
className="sidebar-user-menu-item"
|
|
onClick={openSettingsFromAccountMenu}
|
|
type="button"
|
|
role="menuitem"
|
|
>
|
|
<Settings size={14} />
|
|
<span>设置</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
|
|
title={collapsed ? `${userProfile.displayName}${(userProfile.alias || userProfile.wxid) ? `\n${userProfile.alias || userProfile.wxid}` : ''}` : undefined}
|
|
onClick={() => setIsAccountMenuOpen(prev => !prev)}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault()
|
|
setIsAccountMenuOpen(prev => !prev)
|
|
}
|
|
}}
|
|
>
|
|
<div className="user-avatar">
|
|
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
|
|
</div>
|
|
<div className="user-meta">
|
|
<div className="user-name">{userProfile.displayName}</div>
|
|
<div className="user-wxid">{userProfile.alias || userProfile.wxid || 'wxid 未识别'}</div>
|
|
</div>
|
|
{!collapsed && (
|
|
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
|
|
<ChevronUp size={14} />
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{showSwitchAccountDialog && (
|
|
<div className="sidebar-dialog-overlay" onClick={() => !isSwitchingAccount && setShowSwitchAccountDialog(false)}>
|
|
<div className="sidebar-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
|
<h3>切换账号</h3>
|
|
<p>选择要切换的微信账号</p>
|
|
<div className="sidebar-wxid-list">
|
|
{wxidOptions.map((option) => (
|
|
<button
|
|
key={option.wxid}
|
|
className={`sidebar-wxid-item ${userProfile.wxid === option.wxid ? 'current' : ''}`}
|
|
onClick={() => handleSwitchAccount(option.wxid)}
|
|
disabled={isSwitchingAccount}
|
|
type="button"
|
|
>
|
|
<div className="wxid-avatar">
|
|
{option.avatarUrl ? (
|
|
<img src={option.avatarUrl} alt="" />
|
|
) : (
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', borderRadius: '6px', color: 'var(--text-tertiary)' }}>
|
|
<UserRound size={16} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="wxid-info">
|
|
<div className="wxid-name">{option.displayName}</div>
|
|
{option.displayName !== option.wxid && <div className="wxid-id">{option.wxid}</div>}
|
|
</div>
|
|
{userProfile.wxid === option.wxid && <span className="current-badge">当前</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="sidebar-dialog-actions">
|
|
<button type="button" onClick={() => setShowSwitchAccountDialog(false)} disabled={isSwitchingAccount}>取消</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default Sidebar
|