diff --git a/electron/main.ts b/electron/main.ts index 45b9071..4c492a5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -390,6 +390,10 @@ function registerIpcHandlers() { return chatService.getSessions() }) + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { + return chatService.enrichSessionsContactInfo(usernames) + }) + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { return chatService.getMessages(sessionId, offset, limit) }) diff --git a/electron/preload.ts b/electron/preload.ts index 22f20af..464c069 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -91,6 +91,8 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), + enrichSessionsContactInfo: (usernames: string[]) => + ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), getMessages: (sessionId: string, offset?: number, limit?: number) => ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), getLatestMessages: (sessionId: string, limit?: number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 0b7743e..a1fa1f3 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -166,7 +166,7 @@ class ChatService { } /** - * 获取会话列表 + * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载) */ async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { try { @@ -189,8 +189,10 @@ class ChatService { return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } } - // 转换为 ChatSession + // 转换为 ChatSession(先加载缓存,但不等待数据库查询) const sessions: ChatSession[] = [] + const now = Date.now() + for (const row of rows) { const username = row.username || @@ -225,6 +227,15 @@ class ChatService { const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) + // 先尝试从缓存获取联系人信息(快速路径) + let displayName = username + let avatarUrl: string | undefined = undefined + const cached = this.avatarCache.get(username) + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + displayName = cached.displayName || username + avatarUrl = cached.avatarUrl + } + sessions.push({ username, type: parseInt(row.type || '0', 10), @@ -233,13 +244,13 @@ class ChatService { sortTimestamp: sortTs, lastTimestamp: lastTs, lastMsgType, - displayName: username + displayName, + avatarUrl }) } - // 获取联系人信息 - await this.enrichSessionsWithContacts(sessions) - + // 不等待联系人信息加载,直接返回基础会话列表 + // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } } catch (e) { console.error('ChatService: 获取会话列表失败:', e) @@ -248,45 +259,85 @@ class ChatService { } /** - * 补充联系人信息 + * 异步补充会话列表的联系人信息(公开方法,供前端调用) + */ + async enrichSessionsContactInfo(usernames: string[]): Promise<{ + success: boolean + contacts?: Record + error?: string + }> { + try { + if (usernames.length === 0) { + return { success: true, contacts: {} } + } + + const connectResult = await this.ensureConnected() + if (!connectResult.success) { + return { success: false, error: connectResult.error } + } + + const now = Date.now() + const missing: string[] = [] + const result: Record = {} + + // 检查缓存 + for (const username of usernames) { + const cached = this.avatarCache.get(username) + if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { + result[username] = { + displayName: cached.displayName, + avatarUrl: cached.avatarUrl + } + } else { + missing.push(username) + } + } + + // 批量查询缺失的联系人信息 + if (missing.length > 0) { + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(missing), + wcdbService.getAvatarUrls(missing) + ]) + + for (const username of missing) { + const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined + const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined + + result[username] = { displayName, avatarUrl } + + // 更新缓存 + this.avatarCache.set(username, { + displayName: displayName || username, + avatarUrl, + updatedAt: now + }) + } + } + + return { success: true, contacts: result } + } catch (e) { + console.error('ChatService: 补充联系人信息失败:', e) + return { success: false, error: String(e) } + } + } + + /** + * 补充联系人信息(私有方法,保持向后兼容) */ private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise { if (sessions.length === 0) return try { - const now = Date.now() - const missing: string[] = [] - - for (const session of sessions) { - const cached = this.avatarCache.get(session.username) - if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { - if (cached.displayName) session.displayName = cached.displayName - if (cached.avatarUrl) { - session.avatarUrl = cached.avatarUrl - continue + const usernames = sessions.map(s => s.username) + const result = await this.enrichSessionsContactInfo(usernames) + if (result.success && result.contacts) { + for (const session of sessions) { + const contact = result.contacts![session.username] + if (contact) { + if (contact.displayName) session.displayName = contact.displayName + if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl } } - missing.push(session.username) - } - - if (missing.length === 0) return - const missingSet = new Set(missing) - - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(missing), - wcdbService.getAvatarUrls(missing) - ]) - - for (const session of sessions) { - if (!missingSet.has(session.username)) continue - const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined - const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined - if (displayName) session.displayName = displayName - if (avatarUrl) session.avatarUrl = avatarUrl - this.avatarCache.set(session.username, { - displayName: session.displayName, - avatarUrl: session.avatarUrl, - updatedAt: now - }) } } catch (e) { console.error('ChatService: 获取联系人信息失败:', e) diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index be0ecb3..7e9a5d5 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -648,8 +648,15 @@ export class WcdbService { return { success: false, error: 'WCDB 未连接' } } try { + // 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetSessions(this.handle, outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { this.writeLog(`getSessions failed: code=${result}`) return { success: false, error: `获取会话失败: ${result}` } @@ -706,8 +713,15 @@ export class WcdbService { } if (usernames.length === 0) return { success: true, map: {} } try { + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { return { success: false, error: `获取昵称失败: ${result}` } } @@ -746,8 +760,15 @@ export class WcdbService { return { success: true, map: resultMap } } + // 让出控制权,避免阻塞事件循环 + await new Promise(resolve => setImmediate(resolve)) + const outPtr = [null as any] const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setImmediate(resolve)) + if (result !== 0 || !outPtr[0]) { if (Object.keys(resultMap).length > 0) { return { success: true, map: resultMap, error: `获取头像失败: ${result}` } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 3314201..000e346 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -23,11 +23,128 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } -// 头像组件 - 支持骨架屏加载 -function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { +// 全局头像加载队列管理器(限制并发,避免卡顿) +class AvatarLoadQueue { + private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = [] + private loading = new Set() + private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿 + private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间 + + async enqueue(url: string): Promise { + // 如果已经在加载中,直接返回 + if (this.loading.has(url)) { + return Promise.resolve() + } + + return new Promise((resolve, reject) => { + this.queue.push({ url, resolve, reject }) + this.processQueue() + }) + } + + private async processQueue() { + // 如果已达到最大并发数,等待 + if (this.loading.size >= this.maxConcurrent) { + return + } + + // 如果队列为空,返回 + if (this.queue.length === 0) { + return + } + + // 取出一个任务 + const task = this.queue.shift() + if (!task) return + + this.loading.add(task.url) + + // 加载图片 + const img = new Image() + img.onload = () => { + this.loading.delete(task.url) + task.resolve() + // 延迟一下再处理下一个,避免一次性加载太多 + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.onerror = () => { + this.loading.delete(task.url) + task.reject() + setTimeout(() => this.processQueue(), this.delayBetweenBatches) + } + img.src = task.url + } + + clear() { + this.queue = [] + this.loading.clear() + } +} + +const avatarLoadQueue = new AvatarLoadQueue() + +// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) +// 会话项组件(使用 memo 优化,避免不必要的重渲染) +const SessionItem = React.memo(function SessionItem({ + session, + isActive, + onSelect, + formatTime +}: { + session: ChatSession + isActive: boolean + onSelect: (session: ChatSession) => void + formatTime: (timestamp: number) => string +}) { + // 缓存格式化的时间 + const timeText = useMemo(() => + formatTime(session.lastTimestamp || session.sortTimestamp), + [formatTime, session.lastTimestamp, session.sortTimestamp] + ) + + return ( +
onSelect(session)} + > + +
+
+ {session.displayName || session.username} + {timeText} +
+
+ {session.summary || '暂无消息'} + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
+
+
+ ) +}, (prevProps, nextProps) => { + // 自定义比较:只在关键属性变化时重渲染 + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl && + prevProps.session.summary === nextProps.session.summary && + prevProps.session.unreadCount === nextProps.session.unreadCount && + prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && + prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && + prevProps.isActive === nextProps.isActive + ) +}) + +const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { const [imageLoaded, setImageLoaded] = useState(false) const [imageError, setImageError] = useState(false) + const [shouldLoad, setShouldLoad] = useState(false) + const [isInQueue, setIsInQueue] = useState(false) const imgRef = useRef(null) + const containerRef = useRef(null) const isGroup = session.username.includes('@chatroom') const getAvatarLetter = (): string => { @@ -37,23 +154,63 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu return chars[0] || '?' } + // 使用 Intersection Observer 实现懒加载(优化性能) + useEffect(() => { + if (!containerRef.current || shouldLoad || isInQueue) return + if (!session.avatarUrl) { + // 没有头像URL,不需要加载 + return + } + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isInQueue) { + // 加入加载队列,而不是立即加载 + setIsInQueue(true) + avatarLoadQueue.enqueue(session.avatarUrl!).then(() => { + setShouldLoad(true) + }).catch(() => { + setImageError(true) + }).finally(() => { + setIsInQueue(false) + }) + observer.disconnect() + } + }) + }, + { + rootMargin: '50px' // 减少预加载距离,只提前50px + } + ) + + observer.observe(containerRef.current) + + return () => { + observer.disconnect() + } + }, [session.avatarUrl, shouldLoad, isInQueue]) + // 当 avatarUrl 变化时重置状态 useEffect(() => { setImageLoaded(false) setImageError(false) + setShouldLoad(false) + setIsInQueue(false) }, [session.avatarUrl]) // 检查图片是否已经从缓存加载完成 useEffect(() => { - if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { + if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { setImageLoaded(true) } - }, [session.avatarUrl]) + }, [session.avatarUrl, shouldLoad]) - const hasValidUrl = session.avatarUrl && !imageError + const hasValidUrl = session.avatarUrl && !imageError && shouldLoad return (
@@ -67,6 +224,7 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu className={imageLoaded ? 'loaded' : ''} onLoad={() => setImageLoaded(true)} onError={() => setImageError(true)} + loading="lazy" /> ) : ( @@ -74,7 +232,15 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu )}
) -} +}, (prevProps, nextProps) => { + // 自定义比较函数,只在关键属性变化时重渲染 + return ( + prevProps.session.username === nextProps.session.username && + prevProps.session.displayName === nextProps.session.displayName && + prevProps.session.avatarUrl === nextProps.session.avatarUrl && + prevProps.size === nextProps.size + ) +}) function ChatPage(_props: ChatPageProps) { const { @@ -109,6 +275,7 @@ function ChatPage(_props: ChatPageProps) { const searchInputRef = useRef(null) const sidebarRef = useRef(null) const initialRevealTimerRef = useRef(null) + const sessionListRef = useRef(null) const [currentOffset, setCurrentOffset] = useState(0) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) @@ -120,6 +287,12 @@ function ChatPage(_props: ChatPageProps) { const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false) + + // 联系人信息加载控制 + const isEnrichingRef = useRef(false) + const enrichCancelledRef = useRef(false) + const isScrollingRef = useRef(false) + const sessionScrollTimeoutRef = useRef(null) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) @@ -191,7 +364,7 @@ function ChatPage(_props: ChatPageProps) { } }, [loadMyAvatar]) - // 加载会话列表 + // 加载会话列表(优化:先返回基础数据,异步加载联系人信息) const loadSessions = async (options?: { silent?: boolean }) => { if (options?.silent) { setIsRefreshingSessions(true) @@ -201,8 +374,21 @@ function ChatPage(_props: ChatPageProps) { try { const result = await window.electronAPI.chat.getSessions() if (result.success && result.sessions) { - const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions - setSessions(nextSessions) + // 确保 sessions 是数组 + const sessionsArray = Array.isArray(result.sessions) ? result.sessions : [] + const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray + // 确保 nextSessions 也是数组 + if (Array.isArray(nextSessions)) { + setSessions(nextSessions) + // 延迟启动联系人信息加载,确保UI先渲染完成 + setTimeout(() => { + void enrichSessionsContactInfo(nextSessions) + }, 500) + } else { + console.error('mergeSessions returned non-array:', nextSessions) + setSessions(sessionsArray) + void enrichSessionsContactInfo(sessionsArray) + } } else if (!result.success) { setConnectionError(result.error || '获取会话失败') } @@ -218,6 +404,198 @@ function ChatPage(_props: ChatPageProps) { } } + // 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载) + const enrichSessionsContactInfo = async (sessions: ChatSession[]) => { + if (sessions.length === 0) return + + // 防止重复加载 + if (isEnrichingRef.current) { + console.log('[性能监控] 联系人信息正在加载中,跳过重复请求') + return + } + + isEnrichingRef.current = true + enrichCancelledRef.current = false + + console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`) + const totalStart = performance.now() + + // 延迟启动,等待UI渲染完成 + await new Promise(resolve => setTimeout(resolve, 500)) + + // 检查是否被取消 + if (enrichCancelledRef.current) { + isEnrichingRef.current = false + return + } + + try { + // 找出需要加载联系人信息的会话(没有缓存的) + const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username)) + if (needEnrich.length === 0) { + console.log('[性能监控] 所有联系人信息已缓存,跳过加载') + isEnrichingRef.current = false + return + } + + console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length} 个`) + + // 进一步减少批次大小,每批3个,避免DLL调用阻塞 + const batchSize = 3 + let loadedCount = 0 + + for (let i = 0; i < needEnrich.length; i += batchSize) { + // 如果正在滚动,暂停加载 + if (isScrollingRef.current) { + console.log('[性能监控] 检测到滚动,暂停加载联系人信息') + // 等待滚动结束 + while (isScrollingRef.current && !enrichCancelledRef.current) { + await new Promise(resolve => setTimeout(resolve, 200)) + } + if (enrichCancelledRef.current) break + } + + // 检查是否被取消 + if (enrichCancelledRef.current) break + + const batchStart = performance.now() + const batch = needEnrich.slice(i, i + batchSize) + const usernames = batch.map(s => s.username) + + // 使用 requestIdleCallback 延迟执行,避免阻塞UI + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + void loadContactInfoBatch(usernames).then(() => resolve()) + }, { timeout: 2000 }) + } else { + setTimeout(() => { + void loadContactInfoBatch(usernames).then(() => resolve()) + }, 300) + } + }) + + loadedCount += batch.length + const batchTime = performance.now() - batchStart + if (batchTime > 200) { + console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`) + } + + // 批次间延迟,给UI更多时间(DLL调用可能阻塞,需要更长的延迟) + if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) { + // 如果不在滚动,可以延迟短一点 + const delay = isScrollingRef.current ? 1000 : 800 + await new Promise(resolve => setTimeout(resolve, delay)) + } + } + + const totalTime = performance.now() - totalStart + if (!enrichCancelledRef.current) { + console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`) + } else { + console.log(`[性能监控] 联系人信息加载被取消,已加载: ${loadedCount}/${needEnrich.length}`) + } + } catch (e) { + console.error('加载联系人信息失败:', e) + } finally { + isEnrichingRef.current = false + } + } + + // 联系人信息更新队列(防抖批量更新,避免频繁重渲染) + const contactUpdateQueueRef = useRef>(new Map()) + const contactUpdateTimerRef = useRef(null) + const lastUpdateTimeRef = useRef(0) + + // 批量更新联系人信息(防抖,减少重渲染次数,增加延迟避免阻塞滚动) + const flushContactUpdates = useCallback(() => { + if (contactUpdateTimerRef.current) { + clearTimeout(contactUpdateTimerRef.current) + contactUpdateTimerRef.current = null + } + + // 增加防抖延迟到500ms,避免在滚动时频繁更新 + contactUpdateTimerRef.current = window.setTimeout(() => { + const updates = contactUpdateQueueRef.current + if (updates.size === 0) return + + const now = Date.now() + // 如果距离上次更新太近(小于1秒),继续延迟 + if (now - lastUpdateTimeRef.current < 1000) { + contactUpdateTimerRef.current = window.setTimeout(() => { + flushContactUpdates() + }, 1000 - (now - lastUpdateTimeRef.current)) + return + } + + const { sessions: currentSessions } = useChatStore.getState() + if (!Array.isArray(currentSessions)) return + + let hasChanges = false + const updatedSessions = currentSessions.map(session => { + const update = updates.get(session.username) + if (update) { + const newDisplayName = update.displayName || session.displayName || session.username + const newAvatarUrl = update.avatarUrl || session.avatarUrl + if (newDisplayName !== session.displayName || newAvatarUrl !== session.avatarUrl) { + hasChanges = true + return { + ...session, + displayName: newDisplayName, + avatarUrl: newAvatarUrl + } + } + } + return session + }) + + if (hasChanges) { + const updateStart = performance.now() + setSessions(updatedSessions) + lastUpdateTimeRef.current = Date.now() + const updateTime = performance.now() - updateStart + if (updateTime > 50) { + console.warn(`[性能监控] setSessions更新耗时: ${updateTime.toFixed(2)}ms, 更新了 ${updates.size} 个联系人`) + } + } + + updates.clear() + contactUpdateTimerRef.current = null + }, 500) // 500ms 防抖,减少更新频率 + }, [setSessions]) + + // 加载一批联系人信息并更新会话列表(优化:使用队列批量更新) + const loadContactInfoBatch = async (usernames: string[]) => { + const startTime = performance.now() + try { + // 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate) + await new Promise(resolve => setTimeout(resolve, 0)) + + const dllStart = performance.now() + const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) + const dllTime = performance.now() - dllStart + + // DLL 调用后再次让出控制权 + await new Promise(resolve => setTimeout(resolve, 0)) + + const totalTime = performance.now() - startTime + if (dllTime > 50 || totalTime > 100) { + console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`) + } + + if (result.success && result.contacts) { + // 将更新加入队列,而不是立即更新 + for (const [username, contact] of Object.entries(result.contacts)) { + contactUpdateQueueRef.current.set(username, contact) + } + // 触发批量更新 + flushContactUpdates() + } + } catch (e) { + console.error('加载联系人信息批次失败:', e) + } + } + // 刷新会话列表 const handleRefresh = async () => { await loadSessions({ silent: true }) @@ -329,6 +707,10 @@ function ChatPage(_props: ChatPageProps) { // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) + if (!Array.isArray(sessions)) { + setFilteredSessions([]) + return + } if (!keyword.trim()) { setFilteredSessions(sessions) return @@ -345,27 +727,37 @@ function ChatPage(_props: ChatPageProps) { // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') - setFilteredSessions(sessions) + setFilteredSessions(Array.isArray(sessions) ? sessions : []) } - // 滚动加载更多 + 显示/隐藏回到底部按钮 + // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) + const scrollTimeoutRef = useRef(null) const handleScroll = useCallback(() => { if (!messageListRef.current) return - const { scrollTop, clientHeight, scrollHeight } = messageListRef.current - - // 显示回到底部按钮:距离底部超过 300px - const distanceFromBottom = scrollHeight - scrollTop - clientHeight - setShowScrollToBottom(distanceFromBottom > 300) - - // 预加载:当滚动到顶部 30% 区域时开始加载 - if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { - const threshold = clientHeight * 0.3 - if (scrollTop < threshold) { - loadMessages(currentSessionId, currentOffset) - } + // 节流:延迟执行,避免滚动时频繁计算 + if (scrollTimeoutRef.current) { + cancelAnimationFrame(scrollTimeoutRef.current) } - }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset]) + + scrollTimeoutRef.current = requestAnimationFrame(() => { + if (!messageListRef.current) return + + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current + + // 显示回到底部按钮:距离底部超过 300px + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + setShowScrollToBottom(distanceFromBottom > 300) + + // 预加载:当滚动到顶部 30% 区域时开始加载 + if (!isLoadingMore && !isLoadingMessages && hasMoreMessages && currentSessionId) { + const threshold = clientHeight * 0.3 + if (scrollTop < threshold) { + loadMessages(currentSessionId, currentOffset) + } + } + }) + }, [isLoadingMore, isLoadingMessages, hasMoreMessages, currentSessionId, currentOffset, loadMessages]) const getMessageKey = useCallback((msg: Message): string => { if (msg.localId && msg.localId > 0) return `l:${msg.localId}` @@ -387,7 +779,14 @@ function ChatPage(_props: ChatPageProps) { }, []) const mergeSessions = useCallback((nextSessions: ChatSession[]) => { - if (sessionsRef.current.length === 0) return nextSessions + // 确保输入是数组 + if (!Array.isArray(nextSessions)) { + console.warn('mergeSessions: nextSessions is not an array:', nextSessions) + return Array.isArray(sessionsRef.current) ? sessionsRef.current : [] + } + if (!Array.isArray(sessionsRef.current) || sessionsRef.current.length === 0) { + return nextSessions + } const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s])) return nextSessions.map((next) => { const prev = prevMap.get(next.username) @@ -443,6 +842,20 @@ function ChatPage(_props: ChatPageProps) { if (!isConnected && !isConnecting) { connect() } + + // 组件卸载时清理 + return () => { + avatarLoadQueue.clear() + if (contactUpdateTimerRef.current) { + clearTimeout(contactUpdateTimerRef.current) + } + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + contactUpdateQueueRef.current.clear() + enrichCancelledRef.current = true + isEnrichingRef.current = false + } }, []) useEffect(() => { @@ -499,14 +912,16 @@ function ChatPage(_props: ChatPageProps) { useEffect(() => { const nextMap = new Map() - for (const session of sessions) { - nextMap.set(session.username, session) + if (Array.isArray(sessions)) { + for (const session of sessions) { + nextMap.set(session.username, session) + } } sessionMapRef.current = nextMap }, [sessions]) useEffect(() => { - sessionsRef.current = sessions + sessionsRef.current = Array.isArray(sessions) ? sessions : [] }, [sessions]) useEffect(() => { @@ -570,7 +985,14 @@ function ChatPage(_props: ChatPageProps) { }, [searchKeyword]) useEffect(() => { - if (!searchKeyword.trim()) return + if (!Array.isArray(sessions)) { + setFilteredSessions([]) + return + } + if (!searchKeyword.trim()) { + setFilteredSessions(sessions) + return + } const lower = searchKeyword.toLowerCase() const filtered = sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || @@ -581,8 +1003,8 @@ function ChatPage(_props: ChatPageProps) { }, [sessions, searchKeyword, setFilteredSessions]) - // 格式化会话时间(相对时间)- 与原项目一致 - const formatSessionTime = (timestamp: number): string => { + // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 + const formatSessionTime = useCallback((timestamp: number): string => { if (!Number.isFinite(timestamp) || timestamp <= 0) return '' const now = Date.now() @@ -605,10 +1027,10 @@ function ChatPage(_props: ChatPageProps) { } return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` - } + }, []) // 获取当前会话信息 - const currentSession = sessions.find(s => s.username === currentSessionId) + const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined // 判断是否为群聊 const isGroupChat = (username: string) => username.includes('@chatroom') @@ -691,30 +1113,31 @@ function ChatPage(_props: ChatPageProps) { ))} - ) : filteredSessions.length > 0 ? ( -
+ ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( +
{ + // 标记正在滚动,暂停联系人信息加载 + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + // 滚动结束后200ms才认为滚动停止 + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > {filteredSessions.map(session => ( -
handleSelectSession(session)} - > - -
-
- {session.displayName || session.username} - {formatSessionTime(session.lastTimestamp || session.sortTimestamp)} -
-
- {session.summary || '暂无消息'} - {session.unreadCount > 0 && ( - - {session.unreadCount > 99 ? '99+' : session.unreadCount} - - )} -
-
-
+ session={session} + isActive={currentSessionId === session.username} + onSelect={handleSelectSession} + formatTime={formatSessionTime} + /> ))}
) : ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index eb5c106..35a0bdb 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -55,6 +55,11 @@ export interface ElectronAPI { chat: { connect: () => Promise<{ success: boolean; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> + enrichSessionsContactInfo: (usernames: string[]) => Promise<{ + success: boolean + contacts?: Record + error?: string + }> getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ success: boolean; messages?: Message[];