diff --git a/.gitignore b/.gitignore index e6877f1..66440f0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist dist-electron dist-ssr *.local +test/ # Editor directories and files .vscode/* @@ -42,6 +43,10 @@ release # OS Thumbs.db +# Electron dev cache +.electron/ +.cache/ + # 忽略 Visual Studio 临时文件夹 @@ -50,4 +55,4 @@ Thumbs.db *.ipch *.aps -wcdb/ \ No newline at end of file +wcdb/ diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index de87244..4a5550d 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -19,6 +19,7 @@ interface ChatLabMeta { platform: string type: 'group' | 'private' groupId?: string + groupAvatar?: string } interface ChatLabMember { @@ -425,6 +426,81 @@ class ExportService { return { rows, memberSet, firstTime, lastTime } } + // 补齐群成员,避免只导出发言者导致头像缺失 + private async mergeGroupMembers( + chatroomId: string, + memberSet: Map, + includeAvatars: boolean + ): Promise { + const result = await wcdbService.getGroupMembers(chatroomId) + if (!result.success || !result.members || result.members.length === 0) return + + const rawMembers = result.members as Array<{ + username?: string + avatarUrl?: string + nickname?: string + displayName?: string + remark?: string + originalName?: string + }> + const usernames = rawMembers + .map((member) => member.username) + .filter((username): username is string => Boolean(username)) + if (usernames.length === 0) return + + const lookupUsernames = new Set() + for (const username of usernames) { + lookupUsernames.add(username) + const cleaned = this.cleanAccountDirName(username) + if (cleaned && cleaned !== username) { + lookupUsernames.add(cleaned) + } + } + + const [displayNames, avatarUrls] = await Promise.all([ + wcdbService.getDisplayNames(Array.from(lookupUsernames)), + includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} }) + ]) + + for (const member of rawMembers) { + const username = member.username + if (!username) continue + + const cleaned = this.cleanAccountDirName(username) + const displayName = displayNames.success && displayNames.map + ? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username) + : username + const groupNickname = member.nickname || member.displayName || member.remark || member.originalName + const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map + ? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl) + : member.avatarUrl + + const existing = memberSet.get(username) + if (existing) { + if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) { + existing.member.accountName = displayName + } + if (groupNickname && !existing.member.groupNickname) { + existing.member.groupNickname = groupNickname + } + if (!existing.avatarUrl && avatarUrl) { + existing.avatarUrl = avatarUrl + } + memberSet.set(username, existing) + continue + } + + const chatlabMember: ChatLabMember = { + platformId: username, + accountName: displayName + } + if (groupNickname) { + chatlabMember.groupNickname = groupNickname + } + memberSet.set(username, { member: chatlabMember, avatarUrl }) + } + } + private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null { if (!avatarUrl) return null if (avatarUrl.startsWith('data:')) { @@ -567,6 +643,9 @@ class ExportService { const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const allMessages = collected.rows + if (isGroup) { + await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) + } allMessages.sort((a, b) => a.createTime - b.createTime) @@ -585,6 +664,7 @@ class ExportService { return { sender: msg.senderUsername, accountName: memberInfo.accountName, + groupNickname: memberInfo.groupNickname, timestamp: msg.createTime, type: this.convertMessageType(msg.localType, msg.content), content: this.parseMessageContent(msg.content, msg.localType) @@ -603,6 +683,7 @@ class ExportService { ) : new Map() + const sessionAvatar = avatarMap.get(sessionId) const members = Array.from(collected.memberSet.values()).map((info) => { const avatar = avatarMap.get(info.member.platformId) return avatar ? { ...info.member, avatar } : info.member @@ -618,7 +699,8 @@ class ExportService { name: sessionInfo.displayName, platform: 'wechat', type: isGroup ? 'group' : 'private', - ...(isGroup && { groupId: sessionId }) + ...(isGroup && { groupId: sessionId }), + ...(sessionAvatar && { groupAvatar: sessionAvatar }) }, members, messages: chatLabMessages diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 0faa79f..767cf6d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -5,25 +5,6 @@ import type { ChatSession, Message } from '../types/models' import { getEmojiPath } from 'wechat-emojis' import './ChatPage.scss' -const SESSION_MESSAGE_CACHE_LIMIT = 150 -const SESSION_MESSAGE_CACHE_MAX_ENTRIES = 200 -const sessionMessageCache = new Map() - -const cacheSessionMessages = (sessionId: string, messages: Message[]) => { - if (!sessionId) return - const trimmed = messages.length > SESSION_MESSAGE_CACHE_LIMIT - ? messages.slice(-SESSION_MESSAGE_CACHE_LIMIT) - : messages.slice() - sessionMessageCache.set(sessionId, trimmed) - if (sessionMessageCache.size > SESSION_MESSAGE_CACHE_MAX_ENTRIES) { - const oldestKey = sessionMessageCache.keys().next().value - if (oldestKey) { - sessionMessageCache.delete(oldestKey) - } - } -} - - interface ChatPageProps { // 保留接口以备将来扩展 } @@ -42,6 +23,66 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: 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({ @@ -56,7 +97,7 @@ const SessionItem = React.memo(function SessionItem({ formatTime: (timestamp: number) => string }) { // 缓存格式化的时间 - const timeText = useMemo(() => + const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) @@ -101,6 +142,7 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: 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') @@ -112,13 +154,59 @@ const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: 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(Boolean(session.avatarUrl)) + setShouldLoad(false) + setIsInQueue(false) }, [session.avatarUrl]) - const hasValidUrl = Boolean(session.avatarUrl && !imageError && shouldLoad) + // 检查图片是否已经从缓存加载完成 + useEffect(() => { + if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { + setImageLoaded(true) + } + }, [session.avatarUrl, shouldLoad]) + + const hasValidUrl = session.avatarUrl && !imageError && shouldLoad return (
([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false) - + // 联系人信息加载控制 const isEnrichingRef = useRef(false) const enrichCancelledRef = useRef(false) @@ -292,10 +380,10 @@ function ChatPage(_props: ChatPageProps) { // 确保 nextSessions 也是数组 if (Array.isArray(nextSessions)) { setSessions(nextSessions) - // 启动联系人信息加载,UI 已经渲染完成 + // 延迟启动联系人信息加载,确保UI先渲染完成 setTimeout(() => { void enrichSessionsContactInfo(nextSessions) - }, 0) + }, 500) } else { console.error('mergeSessions returned non-array:', nextSessions) setSessions(sessionsArray) @@ -319,25 +407,28 @@ 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)) @@ -349,10 +440,10 @@ function ChatPage(_props: ChatPageProps) { console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length} 个`) - // 每次最多查询更多联系人,减少批次数 - const batchSize = 20 + // 进一步减少批次大小,每批3个,避免DLL调用阻塞 + const batchSize = 3 let loadedCount = 0 - + for (let i = 0; i < needEnrich.length; i += batchSize) { // 如果正在滚动,暂停加载 if (isScrollingRef.current) { @@ -363,25 +454,41 @@ function ChatPage(_props: ChatPageProps) { } 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) - - // 在执行 DLL 请求前让出控制权以保持响应 - await new Promise(resolve => setTimeout(resolve, 0)) - await loadContactInfoBatch(usernames) - + + // 使用 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}`) @@ -463,19 +570,19 @@ function ChatPage(_props: ChatPageProps) { 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)) { @@ -531,41 +638,16 @@ function ChatPage(_props: ChatPageProps) { } } - const loadCachedMessagesForSession = async (sessionId: string) => { - if (!sessionId) return - const cached = sessionMessageCache.get(sessionId) - if (cached && cached.length > 0) { - setMessages(cached) - setHasInitialMessages(true) - return - } - try { - const result = await window.electronAPI.chat.getCachedMessages(sessionId) - if (result.success && Array.isArray(result.messages) && result.messages.length > 0) { - const trimmed = result.messages.length > SESSION_MESSAGE_CACHE_LIMIT - ? result.messages.slice(-SESSION_MESSAGE_CACHE_LIMIT) - : result.messages - sessionMessageCache.set(sessionId, trimmed) - setMessages(trimmed) - setHasInitialMessages(true) - return - } - } catch (error) { - console.error('加载缓存消息失败:', error) - } - setMessages([]) - setHasInitialMessages(false) - } - // 加载消息 const loadMessages = async (sessionId: string, offset = 0) => { const listEl = messageListRef.current const session = sessionMapRef.current.get(sessionId) const unreadCount = session?.unreadCount ?? 0 const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50 - + if (offset === 0) { setLoadingMessages(true) + setMessages([]) } else { setLoadingMore(true) } @@ -578,7 +660,6 @@ function ChatPage(_props: ChatPageProps) { if (result.success && result.messages) { if (offset === 0) { setMessages(result.messages) - cacheSessionMessages(sessionId, result.messages) // 首次加载滚动到底部 requestAnimationFrame(() => { if (messageListRef.current) { @@ -613,18 +694,14 @@ function ChatPage(_props: ChatPageProps) { // 选择会话 const handleSelectSession = (session: ChatSession) => { if (session.username === currentSessionId) return - const sessionId = session.username - setCurrentSession(sessionId) + setCurrentSession(session.username) setCurrentOffset(0) + loadMessages(session.username, 0) // 重置详情面板 setSessionDetail(null) if (showDetailPanel) { - loadSessionDetail(sessionId) + loadSessionDetail(session.username) } - void (async () => { - await loadCachedMessagesForSession(sessionId) - loadMessages(sessionId, 0) - })() } // 搜索过滤 @@ -665,7 +742,7 @@ function ChatPage(_props: ChatPageProps) { scrollTimeoutRef.current = requestAnimationFrame(() => { if (!messageListRef.current) return - + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current // 显示回到底部按钮:距离底部超过 300px @@ -765,9 +842,10 @@ function ChatPage(_props: ChatPageProps) { if (!isConnected && !isConnecting) { connect() } - + // 组件卸载时清理 return () => { + avatarLoadQueue.clear() if (contactUpdateTimerRef.current) { clearTimeout(contactUpdateTimerRef.current) } @@ -828,7 +906,7 @@ function ChatPage(_props: ChatPageProps) { }) } if (payloads.length > 0) { - window.electronAPI.image.preload(payloads).catch(() => {}) + window.electronAPI.image.preload(payloads).catch(() => { }) } }, [currentSessionId, messages]) @@ -1036,7 +1114,7 @@ function ChatPage(_props: ChatPageProps) { ))}
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ @@ -1117,56 +1195,56 @@ function ChatPage(_props: ChatPageProps) { ref={messageListRef} onScroll={handleScroll} > - {hasMoreMessages && ( -
- {isLoadingMore ? ( - <> - - 加载更多... - - ) : ( - 向上滚动加载更多 - )} -
- )} - - {messages.map((msg, index) => { - const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = msg.localType === 10000 - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - const messageKey = getMessageKey(msg) - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - -
- ) - })} - - {/* 回到底部按钮 */} -
- - 回到底部 + {hasMoreMessages && ( +
+ {isLoadingMore ? ( + <> + + 加载更多... + + ) : ( + 向上滚动加载更多 + )}
+ )} + + {messages.map((msg, index) => { + const prevMsg = index > 0 ? messages[index - 1] : undefined + const showDateDivider = shouldShowDateDivider(msg, prevMsg) + + // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isSystem = msg.localType === 10000 + + // 系统消息居中显示 + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + + const messageKey = getMessageKey(msg) + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + +
+ ) + })} + + {/* 回到底部按钮 */} +
+ + 回到底部
+
{/* 会话详情面板 */} {showDetailPanel && ( @@ -1356,7 +1434,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) { return 'image/webp' } - } catch {} + } catch { } return 'image/jpeg' }, []) @@ -1423,7 +1501,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) } - }).catch(() => {}).finally(() => { + }).catch(() => { }).finally(() => { senderAvatarLoading.delete(sender) }) } @@ -1519,7 +1597,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: } setImageHasUpdate(Boolean(result.hasUpdate)) } - }).catch(() => {}) + }).catch(() => { }) return () => { cancelled = true } @@ -1607,6 +1685,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }: // 是否有引用消息 const hasQuote = message.quotedContent && message.quotedContent.length > 0 + // 去除企业微信 ID 前缀 + const cleanMessageContent = (content: string) => { + if (!content) return '' + return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '') + } + // 解析混合文本和表情 const renderTextWithEmoji = (text: string) => { if (!text) return text @@ -1817,14 +1901,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
{message.quotedSender && {message.quotedSender}} - {renderTextWithEmoji(message.quotedContent || '')} + {renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}
-
{renderTextWithEmoji(message.parsedContent)}
+
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
) } // 普通消息 - return
{renderTextWithEmoji(message.parsedContent)}
+ return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
} return ( diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index d7a08de..c346569 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { {dbKeyStatus &&
{dbKeyStatus}
}
获取密钥会自动识别最近登录的账号
+
如果获取秘钥失败 请在微信打开后等待10秒后再登录
)}