mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 添加联系人信息异步加载功能,优化会话列表展示
This commit is contained in:
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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,46 +259,86 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 补充联系人信息
|
* 异步补充会话列表的联系人信息(公开方法,供前端调用)
|
||||||
*/
|
*/
|
||||||
private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> {
|
async enrichSessionsContactInfo(usernames: string[]): Promise<{
|
||||||
if (sessions.length === 0) return
|
success: boolean
|
||||||
|
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
|
||||||
|
error?: string
|
||||||
|
}> {
|
||||||
try {
|
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 now = Date.now()
|
||||||
const missing: string[] = []
|
const missing: string[] = []
|
||||||
|
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
||||||
|
|
||||||
for (const session of sessions) {
|
// 检查缓存
|
||||||
const cached = this.avatarCache.get(session.username)
|
for (const username of usernames) {
|
||||||
|
const cached = this.avatarCache.get(username)
|
||||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
||||||
if (cached.displayName) session.displayName = cached.displayName
|
result[username] = {
|
||||||
if (cached.avatarUrl) {
|
displayName: cached.displayName,
|
||||||
session.avatarUrl = cached.avatarUrl
|
avatarUrl: cached.avatarUrl
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
missing.push(username)
|
||||||
}
|
}
|
||||||
missing.push(session.username)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length === 0) return
|
// 批量查询缺失的联系人信息
|
||||||
const missingSet = new Set(missing)
|
if (missing.length > 0) {
|
||||||
|
|
||||||
const [displayNames, avatarUrls] = await Promise.all([
|
const [displayNames, avatarUrls] = await Promise.all([
|
||||||
wcdbService.getDisplayNames(missing),
|
wcdbService.getDisplayNames(missing),
|
||||||
wcdbService.getAvatarUrls(missing)
|
wcdbService.getAvatarUrls(missing)
|
||||||
])
|
])
|
||||||
|
|
||||||
for (const session of sessions) {
|
for (const username of missing) {
|
||||||
if (!missingSet.has(session.username)) continue
|
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined
|
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined
|
|
||||||
if (displayName) session.displayName = displayName
|
result[username] = { displayName, avatarUrl }
|
||||||
if (avatarUrl) session.avatarUrl = avatarUrl
|
|
||||||
this.avatarCache.set(session.username, {
|
// 更新缓存
|
||||||
displayName: session.displayName,
|
this.avatarCache.set(username, {
|
||||||
avatarUrl: session.avatarUrl,
|
displayName: displayName || username,
|
||||||
|
avatarUrl,
|
||||||
updatedAt: now
|
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> {
|
||||||
|
if (sessions.length === 0) return
|
||||||
|
try {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取联系人信息失败:', e)
|
console.error('ChatService: 获取联系人信息失败:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` }
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -121,6 +288,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
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])
|
||||||
const messageKeySetRef = useRef<Set<string>>(new Set())
|
const messageKeySetRef = useRef<Set<string>>(new Set())
|
||||||
@@ -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 是数组
|
||||||
|
const sessionsArray = Array.isArray(result.sessions) ? result.sessions : []
|
||||||
|
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
|
||||||
|
// 确保 nextSessions 也是数组
|
||||||
|
if (Array.isArray(nextSessions)) {
|
||||||
setSessions(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,13 +727,22 @@ 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
|
||||||
|
|
||||||
|
// 节流:延迟执行,避免滚动时频繁计算
|
||||||
|
if (scrollTimeoutRef.current) {
|
||||||
|
cancelAnimationFrame(scrollTimeoutRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeoutRef.current = requestAnimationFrame(() => {
|
||||||
|
if (!messageListRef.current) return
|
||||||
|
|
||||||
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
|
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
|
||||||
|
|
||||||
// 显示回到底部按钮:距离底部超过 300px
|
// 显示回到底部按钮:距离底部超过 300px
|
||||||
@@ -365,7 +756,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
loadMessages(currentSessionId, currentOffset)
|
loadMessages(currentSessionId, currentOffset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoadingMore, isLoadingMessages, hasMoreMessages, 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>()
|
||||||
|
if (Array.isArray(sessions)) {
|
||||||
for (const session of sessions) {
|
for (const session of sessions) {
|
||||||
nextMap.set(session.username, session)
|
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">
|
|
||||||
{filteredSessions.map(session => (
|
|
||||||
<div
|
<div
|
||||||
key={session.username}
|
className="session-list"
|
||||||
className={`session-item ${currentSessionId === session.username ? 'active' : ''}`}
|
ref={sessionListRef}
|
||||||
onClick={() => handleSelectSession(session)}
|
onScroll={() => {
|
||||||
|
// 标记正在滚动,暂停联系人信息加载
|
||||||
|
isScrollingRef.current = true
|
||||||
|
if (sessionScrollTimeoutRef.current) {
|
||||||
|
clearTimeout(sessionScrollTimeoutRef.current)
|
||||||
|
}
|
||||||
|
// 滚动结束后200ms才认为滚动停止
|
||||||
|
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
isScrollingRef.current = false
|
||||||
|
sessionScrollTimeoutRef.current = null
|
||||||
|
}, 200)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SessionAvatar session={session} size={48} />
|
{filteredSessions.map(session => (
|
||||||
<div className="session-info">
|
<SessionItem
|
||||||
<div className="session-top">
|
key={session.username}
|
||||||
<span className="session-name">{session.displayName || session.username}</span>
|
session={session}
|
||||||
<span className="session-time">{formatSessionTime(session.lastTimestamp || session.sortTimestamp)}</span>
|
isActive={currentSessionId === session.username}
|
||||||
</div>
|
onSelect={handleSelectSession}
|
||||||
<div className="session-bottom">
|
formatTime={formatSessionTime}
|
||||||
<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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
5
src/types/electron.d.ts
vendored
5
src/types/electron.d.ts
vendored
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user