feat: 添加联系人信息异步加载功能,优化会话列表展示

This commit is contained in:
Forrest
2026-01-12 00:12:42 +08:00
parent e5f57c7359
commit e85254bf98
6 changed files with 601 additions and 95 deletions

View File

@@ -390,6 +390,10 @@ function registerIpcHandlers() {
return chatService.getSessions() 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) => { ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit)
}) })

View File

@@ -91,6 +91,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: { chat: {
connect: () => ipcRenderer.invoke('chat:connect'), connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>

View File

@@ -166,7 +166,7 @@ class ChatService {
} }
/** /**
* 获取会话列表 * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/ */
async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
try { try {
@@ -189,8 +189,10 @@ class ChatService {
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
} }
// 转换为 ChatSession // 转换为 ChatSession(先加载缓存,但不等待数据库查询)
const sessions: ChatSession[] = [] const sessions: ChatSession[] = []
const now = Date.now()
for (const row of rows) { for (const row of rows) {
const username = const username =
row.username || row.username ||
@@ -225,6 +227,15 @@ class ChatService {
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) 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({ sessions.push({
username, username,
type: parseInt(row.type || '0', 10), type: parseInt(row.type || '0', 10),
@@ -233,13 +244,13 @@ class ChatService {
sortTimestamp: sortTs, sortTimestamp: sortTs,
lastTimestamp: lastTs, lastTimestamp: lastTs,
lastMsgType, lastMsgType,
displayName: username displayName,
avatarUrl
}) })
} }
// 获取联系人信息 // 不等待联系人信息加载,直接返回基础会话列表
await this.enrichSessionsWithContacts(sessions) // 前端可以异步调用 enrichSessionsWithContacts 来补充信息
return { success: true, sessions } return { success: true, sessions }
} catch (e) { } catch (e) {
console.error('ChatService: 获取会话列表失败:', e) console.error('ChatService: 获取会话列表失败:', e)
@@ -248,45 +259,85 @@ class ChatService {
} }
/** /**
* 补充联系人信息 * 异步补充会话列表的联系人信息(公开方法,供前端调用)
*/
async enrichSessionsContactInfo(usernames: string[]): Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
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<string, { displayName?: string; avatarUrl?: string }> = {}
// 检查缓存
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<void> { private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> {
if (sessions.length === 0) return if (sessions.length === 0) return
try { try {
const now = Date.now() const usernames = sessions.map(s => s.username)
const missing: string[] = [] const result = await this.enrichSessionsContactInfo(usernames)
if (result.success && result.contacts) {
for (const session of sessions) { for (const session of sessions) {
const cached = this.avatarCache.get(session.username) const contact = result.contacts![session.username]
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { if (contact) {
if (cached.displayName) session.displayName = cached.displayName if (contact.displayName) session.displayName = contact.displayName
if (cached.avatarUrl) { if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl
session.avatarUrl = cached.avatarUrl
continue
} }
} }
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) { } catch (e) {
console.error('ChatService: 获取联系人信息失败:', e) console.error('ChatService: 获取联系人信息失败:', e)

View File

@@ -648,8 +648,15 @@ export class WcdbService {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
} }
try { try {
// 使用 setImmediate 让事件循环有机会处理其他任务,避免长时间阻塞
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetSessions(this.handle, outPtr) const result = this.wcdbGetSessions(this.handle, outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
this.writeLog(`getSessions failed: code=${result}`) this.writeLog(`getSessions failed: code=${result}`)
return { success: false, error: `获取会话失败: ${result}` } return { success: false, error: `获取会话失败: ${result}` }
@@ -706,8 +713,15 @@ export class WcdbService {
} }
if (usernames.length === 0) return { success: true, map: {} } if (usernames.length === 0) return { success: true, map: {} }
try { try {
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr) const result = this.wcdbGetDisplayNames(this.handle, JSON.stringify(usernames), outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取昵称失败: ${result}` } return { success: false, error: `获取昵称失败: ${result}` }
} }
@@ -746,8 +760,15 @@ export class WcdbService {
return { success: true, map: resultMap } return { success: true, map: resultMap }
} }
// 让出控制权,避免阻塞事件循环
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr) const result = this.wcdbGetAvatarUrls(this.handle, JSON.stringify(toFetch), outPtr)
// DLL 调用后再次让出控制权
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
if (Object.keys(resultMap).length > 0) { if (Object.keys(resultMap).length > 0) {
return { success: true, map: resultMap, error: `获取头像失败: ${result}` } return { success: true, map: resultMap, error: `获取头像失败: ${result}` }

View File

@@ -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 { 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 { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
@@ -23,11 +23,128 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] 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<string>()
private readonly maxConcurrent = 1 // 一次只加载1个头像避免卡顿
private readonly delayBetweenBatches = 100 // 批次间延迟100ms给UI喘息时间
async enqueue(url: string): Promise<void> {
// 如果已经在加载中,直接返回
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 (
<div
className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<SessionAvatar session={session} size={48} />
<div className="session-info">
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
<span className="session-time">{timeText}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
)
}, (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 [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false) const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(false)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null) const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isGroup = session.username.includes('@chatroom') const isGroup = session.username.includes('@chatroom')
const getAvatarLetter = (): string => { const getAvatarLetter = (): string => {
@@ -37,23 +154,63 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
return chars[0] || '?' 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 变化时重置状态 // 当 avatarUrl 变化时重置状态
useEffect(() => { useEffect(() => {
setImageLoaded(false) setImageLoaded(false)
setImageError(false) setImageError(false)
setShouldLoad(false)
setIsInQueue(false)
}, [session.avatarUrl]) }, [session.avatarUrl])
// 检查图片是否已经从缓存加载完成 // 检查图片是否已经从缓存加载完成
useEffect(() => { useEffect(() => {
if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true) setImageLoaded(true)
} }
}, [session.avatarUrl]) }, [session.avatarUrl, shouldLoad])
const hasValidUrl = session.avatarUrl && !imageError const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
return ( return (
<div <div
ref={containerRef}
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`} className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
style={{ width: size, height: size }} style={{ width: size, height: size }}
> >
@@ -67,6 +224,7 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
className={imageLoaded ? 'loaded' : ''} className={imageLoaded ? 'loaded' : ''}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
loading="lazy"
/> />
</> </>
) : ( ) : (
@@ -74,7 +232,15 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu
)} )}
</div> </div>
) )
} }, (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) { function ChatPage(_props: ChatPageProps) {
const { const {
@@ -109,6 +275,7 @@ function ChatPage(_props: ChatPageProps) {
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const sidebarRef = useRef<HTMLDivElement>(null) const sidebarRef = useRef<HTMLDivElement>(null)
const initialRevealTimerRef = useRef<number | null>(null) const initialRevealTimerRef = useRef<number | null>(null)
const sessionListRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0) const [currentOffset, setCurrentOffset] = useState(0)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined) const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [showScrollToBottom, setShowScrollToBottom] = useState(false)
@@ -120,6 +287,12 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false)
// 联系人信息加载控制
const isEnrichingRef = useRef(false)
const enrichCancelledRef = useRef(false)
const isScrollingRef = useRef(false)
const sessionScrollTimeoutRef = useRef<number | null>(null)
const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys]) const highlightedMessageSet = useMemo(() => new Set(highlightedMessageKeys), [highlightedMessageKeys])
@@ -191,7 +364,7 @@ function ChatPage(_props: ChatPageProps) {
} }
}, [loadMyAvatar]) }, [loadMyAvatar])
// 加载会话列表 // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
setIsRefreshingSessions(true) setIsRefreshingSessions(true)
@@ -201,8 +374,21 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const result = await window.electronAPI.chat.getSessions() const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions) { if (result.success && result.sessions) {
const nextSessions = options?.silent ? mergeSessions(result.sessions) : result.sessions // 确保 sessions 是数组
setSessions(nextSessions) 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) { } else if (!result.success) {
setConnectionError(result.error || '获取会话失败') 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<void>((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<Map<string, { displayName?: string; avatarUrl?: string }>>(new Map())
const contactUpdateTimerRef = useRef<number | null>(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 () => { const handleRefresh = async () => {
await loadSessions({ silent: true }) await loadSessions({ silent: true })
@@ -329,6 +707,10 @@ function ChatPage(_props: ChatPageProps) {
// 搜索过滤 // 搜索过滤
const handleSearch = (keyword: string) => { const handleSearch = (keyword: string) => {
setSearchKeyword(keyword) setSearchKeyword(keyword)
if (!Array.isArray(sessions)) {
setFilteredSessions([])
return
}
if (!keyword.trim()) { if (!keyword.trim()) {
setFilteredSessions(sessions) setFilteredSessions(sessions)
return return
@@ -345,27 +727,37 @@ function ChatPage(_props: ChatPageProps) {
// 关闭搜索框 // 关闭搜索框
const handleCloseSearch = () => { const handleCloseSearch = () => {
setSearchKeyword('') setSearchKeyword('')
setFilteredSessions(sessions) setFilteredSessions(Array.isArray(sessions) ? sessions : [])
} }
// 滚动加载更多 + 显示/隐藏回到底部按钮 // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
const scrollTimeoutRef = useRef<number | null>(null)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (!messageListRef.current) return if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 节流:延迟执行,避免滚动时频繁计算
if (scrollTimeoutRef.current) {
// 显示回到底部按钮:距离底部超过 300px cancelAnimationFrame(scrollTimeoutRef.current)
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])
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 => { const getMessageKey = useCallback((msg: Message): string => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}` if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
@@ -387,7 +779,14 @@ function ChatPage(_props: ChatPageProps) {
}, []) }, [])
const mergeSessions = useCallback((nextSessions: ChatSession[]) => { 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])) const prevMap = new Map(sessionsRef.current.map((s) => [s.username, s]))
return nextSessions.map((next) => { return nextSessions.map((next) => {
const prev = prevMap.get(next.username) const prev = prevMap.get(next.username)
@@ -443,6 +842,20 @@ function ChatPage(_props: ChatPageProps) {
if (!isConnected && !isConnecting) { if (!isConnected && !isConnecting) {
connect() 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(() => { useEffect(() => {
@@ -499,14 +912,16 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => { useEffect(() => {
const nextMap = new Map<string, ChatSession>() const nextMap = new Map<string, ChatSession>()
for (const session of sessions) { if (Array.isArray(sessions)) {
nextMap.set(session.username, session) for (const session of sessions) {
nextMap.set(session.username, session)
}
} }
sessionMapRef.current = nextMap sessionMapRef.current = nextMap
}, [sessions]) }, [sessions])
useEffect(() => { useEffect(() => {
sessionsRef.current = sessions sessionsRef.current = Array.isArray(sessions) ? sessions : []
}, [sessions]) }, [sessions])
useEffect(() => { useEffect(() => {
@@ -570,7 +985,14 @@ function ChatPage(_props: ChatPageProps) {
}, [searchKeyword]) }, [searchKeyword])
useEffect(() => { useEffect(() => {
if (!searchKeyword.trim()) return if (!Array.isArray(sessions)) {
setFilteredSessions([])
return
}
if (!searchKeyword.trim()) {
setFilteredSessions(sessions)
return
}
const lower = searchKeyword.toLowerCase() const lower = searchKeyword.toLowerCase()
const filtered = sessions.filter(s => const filtered = sessions.filter(s =>
s.displayName?.toLowerCase().includes(lower) || s.displayName?.toLowerCase().includes(lower) ||
@@ -581,8 +1003,8 @@ function ChatPage(_props: ChatPageProps) {
}, [sessions, searchKeyword, setFilteredSessions]) }, [sessions, searchKeyword, setFilteredSessions])
// 格式化会话时间(相对时间)- 与原项目一致 // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算
const formatSessionTime = (timestamp: number): string => { const formatSessionTime = useCallback((timestamp: number): string => {
if (!Number.isFinite(timestamp) || timestamp <= 0) return '' if (!Number.isFinite(timestamp) || timestamp <= 0) return ''
const now = Date.now() const now = Date.now()
@@ -605,10 +1027,10 @@ function ChatPage(_props: ChatPageProps) {
} }
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` 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') const isGroupChat = (username: string) => username.includes('@chatroom')
@@ -691,30 +1113,31 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
))} ))}
</div> </div>
) : filteredSessions.length > 0 ? ( ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div className="session-list"> <div
className="session-list"
ref={sessionListRef}
onScroll={() => {
// 标记正在滚动,暂停联系人信息加载
isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
// 滚动结束后200ms才认为滚动停止
sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false
sessionScrollTimeoutRef.current = null
}, 200)
}}
>
{filteredSessions.map(session => ( {filteredSessions.map(session => (
<div <SessionItem
key={session.username} key={session.username}
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`} session={session}
onClick={() => handleSelectSession(session)} isActive={currentSessionId === session.username}
> onSelect={handleSelectSession}
<SessionAvatar session={session} size={48} /> formatTime={formatSessionTime}
<div className="session-info"> />
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
<span className="session-time">{formatSessionTime(session.lastTimestamp || session.sortTimestamp)}</span>
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '暂无消息'}</span>
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
))} ))}
</div> </div>
) : ( ) : (

View File

@@ -55,6 +55,11 @@ export interface ElectronAPI {
chat: { chat: {
connect: () => Promise<{ success: boolean; error?: string }> connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];