From 794a306f897f1ef9b9fc053a5d3a2ced5cff1569 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Sun, 1 Mar 2026 19:03:15 +0800 Subject: [PATCH] perf(contacts): speed up directory loading and smooth list rendering --- electron/services/chatService.ts | 114 ++++++----- src/pages/ContactsPage.scss | 25 ++- src/pages/ContactsPage.tsx | 341 +++++++++++++++++++++++-------- 3 files changed, 335 insertions(+), 145 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 0e655d3..00958cf 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -684,40 +684,52 @@ class ChatService { if (!headImageDbPath) return result - // 使用 wcdbService.execQuery 查询加密的 head_image.db - for (const username of usernames) { - try { - const escapedUsername = username.replace(/'/g, "''") - const queryResult = await wcdbService.execQuery( - 'media', - headImageDbPath, - `SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` - ) + const normalizedUsernames = Array.from( + new Set( + usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) return result - if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { - const row = queryResult.rows[0] as any - if (row?.image_buffer) { - let base64Data: string - if (typeof row.image_buffer === 'string') { - // WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 - if (row.image_buffer.toLowerCase().startsWith('ffd8')) { - const buffer = Buffer.from(row.image_buffer, 'hex') - base64Data = buffer.toString('base64') - } else { - base64Data = row.image_buffer - } - } else if (Buffer.isBuffer(row.image_buffer)) { - base64Data = row.image_buffer.toString('base64') - } else if (Array.isArray(row.image_buffer)) { - base64Data = Buffer.from(row.image_buffer).toString('base64') - } else { - continue - } - result[username] = `data:image/jpeg;base64,${base64Data}` + const batchSize = 320 + for (let i = 0; i < normalizedUsernames.length; i += batchSize) { + const batch = normalizedUsernames.slice(i, i + batchSize) + if (batch.length === 0) continue + const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',') + const queryResult = await wcdbService.execQuery( + 'media', + headImageDbPath, + `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})` + ) + + if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) { + continue + } + + for (const row of queryResult.rows as any[]) { + const username = String(row?.username || '').trim() + if (!username || !row?.image_buffer) continue + + let base64Data: string | null = null + if (typeof row.image_buffer === 'string') { + // WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64 + if (row.image_buffer.toLowerCase().startsWith('ffd8')) { + const buffer = Buffer.from(row.image_buffer, 'hex') + base64Data = buffer.toString('base64') + } else { + base64Data = row.image_buffer } + } else if (Buffer.isBuffer(row.image_buffer)) { + base64Data = row.image_buffer.toString('base64') + } else if (Array.isArray(row.image_buffer)) { + base64Data = Buffer.from(row.image_buffer).toString('base64') + } + + if (base64Data) { + result[username] = `data:image/jpeg;base64,${base64Data}` } - } catch { - // 静默处理单个用户的错误 } } } catch (e) { @@ -949,11 +961,17 @@ class ChatService { // 使用execQuery直接查询加密的contact.db // kind='contact', path=null表示使用已打开的contact.db const contactQuery = ` - SELECT username, remark, nick_name, alias, local_type, flag, quan_pin + SELECT username, remark, nick_name, alias, local_type, quan_pin FROM contact + WHERE username IS NOT NULL + AND username != '' + AND ( + username LIKE '%@chatroom' + OR username LIKE 'gh_%' + OR local_type = 1 + OR (local_type = 0 AND COALESCE(quan_pin, '') != '') + ) ` - - const contactResult = await wcdbService.execQuery('contact', null, contactQuery) if (!contactResult.success || !contactResult.rows) { @@ -963,21 +981,6 @@ class ChatService { const rows = contactResult.rows as Record[] - - // 调试:显示前5条数据样本 - - rows.slice(0, 5).forEach((row, idx) => { - - }) - - // 调试:统计local_type分布 - const localTypeStats = new Map() - rows.forEach(row => { - const lt = row.local_type || 0 - localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) - }) - - // 获取会话表的最后联系时间用于排序 const lastContactTimeMap = new Map() const sessionResult = await wcdbService.getSessions() @@ -993,25 +996,24 @@ class ChatService { // 转换为ContactInfo const contacts: (ContactInfo & { lastContactTime: number })[] = [] + const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']) for (const row of rows) { - const username = row.username || '' + const username = String(row.username || '').trim() if (!username) continue - const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'] let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) - const flag = Number(row.flag ?? 0) - const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '' + const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim() - if (username.includes('@chatroom')) { + if (username.endsWith('@chatroom')) { type = 'group' } else if (username.startsWith('gh_')) { type = 'official' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { + } else if (localType === 1 && !excludeNames.has(username)) { type = 'friend' - } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { + } else if (localType === 0 && quanPin) { type = 'former_friend' } else { continue diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index f7986ff..6bb4844 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -177,6 +177,12 @@ padding: 0 20px 12px; font-size: 13px; color: var(--text-secondary); + + .avatar-enrich-progress { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + } } .selection-toolbar { @@ -217,6 +223,7 @@ flex: 1; overflow-y: auto; padding: 0 12px 12px; + position: relative; &::-webkit-scrollbar { width: 6px; @@ -229,15 +236,31 @@ } } + .contacts-list-virtual { + position: relative; + min-height: 100%; + } + + .contact-row { + position: absolute; + left: 0; + right: 0; + height: 76px; + padding-bottom: 4px; + will-change: transform; + } + .contact-item { display: flex; align-items: center; gap: 12px; padding: 12px; + height: 72px; + box-sizing: border-box; border-radius: 10px; transition: all 0.2s; cursor: pointer; - margin-bottom: 4px; + margin-bottom: 0; &:hover { background: var(--bg-hover); diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 1f74576..570b40c 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef } from 'react' +import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react' import { useNavigate } from 'react-router-dom' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { useChatStore } from '../stores/chatStore' @@ -13,12 +13,22 @@ interface ContactInfo { type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' } +interface ContactEnrichInfo { + displayName?: string + avatarUrl?: string +} + +const AVATAR_ENRICH_BATCH_SIZE = 80 +const SEARCH_DEBOUNCE_MS = 120 +const VIRTUAL_ROW_HEIGHT = 76 +const VIRTUAL_OVERSCAN = 10 + function ContactsPage() { const [contacts, setContacts] = useState([]) - const [filteredContacts, setFilteredContacts] = useState([]) const [selectedUsernames, setSelectedUsernames] = useState>(new Set()) const [isLoading, setIsLoading] = useState(true) const [searchKeyword, setSearchKeyword] = useState('') + const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('') const [contactTypes, setContactTypes] = useState({ friends: true, groups: false, @@ -39,79 +49,193 @@ function ContactsPage() { const [isExporting, setIsExporting] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false) const formatDropdownRef = useRef(null) + const listRef = useRef(null) + const loadVersionRef = useRef(0) + const [avatarEnrichProgress, setAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) + const [scrollTop, setScrollTop] = useState(0) + const [listViewportHeight, setListViewportHeight] = useState(480) + + const applyEnrichedContacts = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + + setContacts(prev => { + let changed = false + const next = prev.map(contact => { + const enriched = enrichedMap[contact.username] + if (!enriched) return contact + const displayName = enriched.displayName || contact.displayName + const avatarUrl = enriched.avatarUrl || contact.avatarUrl + if (displayName === contact.displayName && avatarUrl === contact.avatarUrl) { + return contact + } + changed = true + return { + ...contact, + displayName, + avatarUrl + } + }) + return changed ? next : prev + }) + + setSelectedContact(prev => { + if (!prev) return prev + const enriched = enrichedMap[prev.username] + if (!enriched) return prev + const displayName = enriched.displayName || prev.displayName + const avatarUrl = enriched.avatarUrl || prev.avatarUrl + if (displayName === prev.displayName && avatarUrl === prev.avatarUrl) { + return prev + } + return { + ...prev, + displayName, + avatarUrl + } + }) + }, []) + + const enrichContactsInBackground = useCallback(async (sourceContacts: ContactInfo[], loadVersion: number) => { + const usernames = sourceContacts.map(contact => contact.username).filter(Boolean) + const total = usernames.length + setAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += AVATAR_ENRICH_BATCH_SIZE) { + if (loadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (loadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContacts(avatarResult.contacts) + } + } catch (e) { + console.error('分批补全头像失败:', e) + } + + const loaded = Math.min(i + batch.length, total) + setAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + } + }, [applyEnrichedContacts]) // 加载通讯录 const loadContacts = useCallback(async () => { + const loadVersion = loadVersionRef.current + 1 + loadVersionRef.current = loadVersion setIsLoading(true) + setAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) try { - const result = await window.electronAPI.chat.connect() - if (!result.success) { - console.error('连接失败:', result.error) - setIsLoading(false) - return - } const contactsResult = await window.electronAPI.chat.getContacts() - + + if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { - - - - // 获取头像URL - const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username) - if (usernames.length > 0) { - const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) - if (avatarResult.success && avatarResult.contacts) { - contactsResult.contacts.forEach((contact: ContactInfo) => { - const enriched = avatarResult.contacts?.[contact.username] - if (enriched?.avatarUrl) { - contact.avatarUrl = enriched.avatarUrl - } - }) - } - } - setContacts(contactsResult.contacts) - setFilteredContacts(contactsResult.contacts) setSelectedUsernames(new Set()) + setSelectedContact(prev => { + if (!prev) return prev + return contactsResult.contacts!.find(contact => contact.username === prev.username) || null + }) + setIsLoading(false) + void enrichContactsInBackground(contactsResult.contacts, loadVersion) + return } } catch (e) { console.error('加载通讯录失败:', e) } finally { - setIsLoading(false) + if (loadVersionRef.current === loadVersion) { + setIsLoading(false) + } } - }, []) + }, [enrichContactsInBackground]) useEffect(() => { loadContacts() }, [loadContacts]) - // 搜索和类型过滤 useEffect(() => { - let filtered = contacts + return () => { + loadVersionRef.current += 1 + } + }, []) - // 类型过滤 - filtered = filtered.filter(c => { - if (c.type === 'friend' && !contactTypes.friends) return false - if (c.type === 'group' && !contactTypes.groups) return false - if (c.type === 'official' && !contactTypes.officials) return false - if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase()) + }, SEARCH_DEBOUNCE_MS) + return () => window.clearTimeout(timer) + }, [searchKeyword]) + + const filteredContacts = useMemo(() => { + let filtered = contacts.filter(contact => { + if (contact.type === 'friend' && !contactTypes.friends) return false + if (contact.type === 'group' && !contactTypes.groups) return false + if (contact.type === 'official' && !contactTypes.officials) return false + if (contact.type === 'former_friend' && !contactTypes.deletedFriends) return false return true }) - // 关键词过滤 - if (searchKeyword.trim()) { - const lower = searchKeyword.toLowerCase() - filtered = filtered.filter(c => - c.displayName?.toLowerCase().includes(lower) || - c.remark?.toLowerCase().includes(lower) || - c.username.toLowerCase().includes(lower) + if (debouncedSearchKeyword) { + filtered = filtered.filter(contact => + contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) || + contact.remark?.toLowerCase().includes(debouncedSearchKeyword) || + contact.username.toLowerCase().includes(debouncedSearchKeyword) ) } - setFilteredContacts(filtered) - }, [searchKeyword, contacts, contactTypes]) + return filtered + }, [contacts, contactTypes, debouncedSearchKeyword]) - // 点击外部关闭下拉菜单 + useEffect(() => { + if (!listRef.current) return + listRef.current.scrollTop = 0 + setScrollTop(0) + }, [debouncedSearchKeyword, contactTypes]) + + useEffect(() => { + const node = listRef.current + if (!node) return + + const updateViewportHeight = () => { + setListViewportHeight(Math.max(node.clientHeight, VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * VIRTUAL_ROW_HEIGHT - listViewportHeight) + if (scrollTop <= maxScroll) return + setScrollTop(maxScroll) + if (listRef.current) { + listRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + // 搜索和类型过滤 useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -123,11 +247,35 @@ function ContactsPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showFormatSelect]) - const selectedInFilteredCount = filteredContacts.reduce((count, contact) => { - return selectedUsernames.has(contact.username) ? count + 1 : count - }, 0) + const selectedInFilteredCount = useMemo(() => { + return filteredContacts.reduce((count, contact) => { + return selectedUsernames.has(contact.username) ? count + 1 : count + }, 0) + }, [filteredContacts, selectedUsernames]) const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length + const { startIndex, endIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(scrollTop / VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(listViewportHeight / VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, listViewportHeight, scrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(startIndex, endIndex) + }, [filteredContacts, startIndex, endIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setScrollTop(event.currentTarget.scrollTop) + }, []) + const toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -297,7 +445,12 @@ function ContactsPage() {
- 共 {filteredContacts.length} 个联系人 + 共 {filteredContacts.length} / {contacts.length} 个联系人 + {avatarEnrichProgress.running && ( + + 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} + + )}
{exportMode && ( @@ -315,61 +468,73 @@ function ContactsPage() { )} - {isLoading ? ( + {isLoading && contacts.length === 0 ? (
- 加载中... + 联系人加载中...
) : filteredContacts.length === 0 ? (
暂无联系人
) : ( -
- {filteredContacts.map(contact => { +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = startIndex + idx + const top = absoluteIndex * VIRTUAL_ROW_HEIGHT const isChecked = selectedUsernames.has(contact.username) const isActive = !exportMode && selectedContact?.username === contact.username return (
{ - if (exportMode) { - toggleContactSelected(contact.username, !isChecked) - } else { - setSelectedContact(isActive ? null : contact) - } - }} + className="contact-row" + style={{ transform: `translateY(${top}px)` }} > - {exportMode && ( - - )} -
- {contact.avatarUrl ? ( - - ) : ( - {getAvatarLetter(contact.displayName)} +
{ + if (exportMode) { + toggleContactSelected(contact.username, !isChecked) + } else { + setSelectedContact(isActive ? null : contact) + } + }} + > + {exportMode && ( + )} -
-
-
{contact.displayName}
- {contact.remark && contact.remark !== contact.displayName && ( -
备注: {contact.remark}
- )} -
-
- {getContactTypeIcon(contact.type)} - {getContactTypeName(contact.type)} +
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+ {contact.remark && contact.remark !== contact.displayName && ( +
备注: {contact.remark}
+ )} +
+
+ {getContactTypeIcon(contact.type)} + {getContactTypeName(contact.type)} +
) - })} + })} +
)}