perf(contacts): speed up directory loading and smooth list rendering

This commit is contained in:
tisonhuang
2026-03-01 19:03:15 +08:00
parent ac61ee1833
commit 794a306f89
3 changed files with 335 additions and 145 deletions

View File

@@ -684,40 +684,52 @@ class ChatService {
if (!headImageDbPath) return result if (!headImageDbPath) return result
// 使用 wcdbService.execQuery 查询加密的 head_image.db const normalizedUsernames = Array.from(
for (const username of usernames) { new Set(
try { usernames
const escapedUsername = username.replace(/'/g, "''") .map((username) => String(username || '').trim())
const queryResult = await wcdbService.execQuery( .filter(Boolean)
'media', )
headImageDbPath, )
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1` if (normalizedUsernames.length === 0) return result
)
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) { const batchSize = 320
const row = queryResult.rows[0] as any for (let i = 0; i < normalizedUsernames.length; i += batchSize) {
if (row?.image_buffer) { const batch = normalizedUsernames.slice(i, i + batchSize)
let base64Data: string if (batch.length === 0) continue
if (typeof row.image_buffer === 'string') { const usernamesExpr = batch.map((name) => `'${this.escapeSqlString(name)}'`).join(',')
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64 const queryResult = await wcdbService.execQuery(
if (row.image_buffer.toLowerCase().startsWith('ffd8')) { 'media',
const buffer = Buffer.from(row.image_buffer, 'hex') headImageDbPath,
base64Data = buffer.toString('base64') `SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})`
} else { )
base64Data = row.image_buffer
} if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) {
} else if (Buffer.isBuffer(row.image_buffer)) { continue
base64Data = row.image_buffer.toString('base64') }
} else if (Array.isArray(row.image_buffer)) {
base64Data = Buffer.from(row.image_buffer).toString('base64') for (const row of queryResult.rows as any[]) {
} else { const username = String(row?.username || '').trim()
continue if (!username || !row?.image_buffer) continue
}
result[username] = `data:image/jpeg;base64,${base64Data}` 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) { } catch (e) {
@@ -949,11 +961,17 @@ class ChatService {
// 使用execQuery直接查询加密的contact.db // 使用execQuery直接查询加密的contact.db
// kind='contact', path=null表示使用已打开的contact.db // kind='contact', path=null表示使用已打开的contact.db
const contactQuery = ` 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 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) const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
if (!contactResult.success || !contactResult.rows) { if (!contactResult.success || !contactResult.rows) {
@@ -963,21 +981,6 @@ class ChatService {
const rows = contactResult.rows as Record<string, any>[] const rows = contactResult.rows as Record<string, any>[]
// 调试显示前5条数据样本
rows.slice(0, 5).forEach((row, idx) => {
})
// 调试统计local_type分布
const localTypeStats = new Map<number, number>()
rows.forEach(row => {
const lt = row.local_type || 0
localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1)
})
// 获取会话表的最后联系时间用于排序 // 获取会话表的最后联系时间用于排序
const lastContactTimeMap = new Map<string, number>() const lastContactTimeMap = new Map<string, number>()
const sessionResult = await wcdbService.getSessions() const sessionResult = await wcdbService.getSessions()
@@ -993,25 +996,24 @@ class ChatService {
// 转换为ContactInfo // 转换为ContactInfo
const contacts: (ContactInfo & { lastContactTime: number })[] = [] const contacts: (ContactInfo & { lastContactTime: number })[] = []
const excludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
for (const row of rows) { for (const row of rows) {
const username = row.username || '' const username = String(row.username || '').trim()
if (!username) continue if (!username) continue
const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']
let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other' let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0) const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const flag = Number(row.flag ?? 0) const quanPin = String(this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || '').trim()
const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || ''
if (username.includes('@chatroom')) { if (username.endsWith('@chatroom')) {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
type = 'official' type = 'official'
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) { } else if (localType === 1 && !excludeNames.has(username)) {
type = 'friend' type = 'friend'
} else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) { } else if (localType === 0 && quanPin) {
type = 'former_friend' type = 'former_friend'
} else { } else {
continue continue

View File

@@ -177,6 +177,12 @@
padding: 0 20px 12px; padding: 0 20px 12px;
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
.avatar-enrich-progress {
margin-left: 10px;
color: var(--text-tertiary);
font-size: 12px;
}
} }
.selection-toolbar { .selection-toolbar {
@@ -217,6 +223,7 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 0 12px 12px; padding: 0 12px 12px;
position: relative;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 6px; 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 { .contact-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
height: 72px;
box-sizing: border-box;
border-radius: 10px; border-radius: 10px;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer; cursor: pointer;
margin-bottom: 4px; margin-bottom: 0;
&:hover { &:hover {
background: var(--bg-hover); background: var(--bg-hover);

View File

@@ -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 { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react' import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
@@ -13,12 +13,22 @@ interface ContactInfo {
type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' 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() { function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([]) const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set()) const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({ const [contactTypes, setContactTypes] = useState({
friends: true, friends: true,
groups: false, groups: false,
@@ -39,79 +49,193 @@ function ContactsPage() {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false) const [showFormatSelect, setShowFormatSelect] = useState(false)
const formatDropdownRef = useRef<HTMLDivElement>(null) const formatDropdownRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(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<string, ContactEnrichInfo>) => {
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 loadContacts = useCallback(async () => {
const loadVersion = loadVersionRef.current + 1
loadVersionRef.current = loadVersion
setIsLoading(true) setIsLoading(true)
setAvatarEnrichProgress({
loaded: 0,
total: 0,
running: false
})
try { 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() const contactsResult = await window.electronAPI.chat.getContacts()
if (loadVersionRef.current !== loadVersion) return
if (contactsResult.success && contactsResult.contacts) { 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) setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set()) 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) { } catch (e) {
console.error('加载通讯录失败:', e) console.error('加载通讯录失败:', e)
} finally { } finally {
setIsLoading(false) if (loadVersionRef.current === loadVersion) {
setIsLoading(false)
}
} }
}, []) }, [enrichContactsInBackground])
useEffect(() => { useEffect(() => {
loadContacts() loadContacts()
}, [loadContacts]) }, [loadContacts])
// 搜索和类型过滤
useEffect(() => { useEffect(() => {
let filtered = contacts return () => {
loadVersionRef.current += 1
}
}, [])
// 类型过滤 useEffect(() => {
filtered = filtered.filter(c => { const timer = window.setTimeout(() => {
if (c.type === 'friend' && !contactTypes.friends) return false setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
if (c.type === 'group' && !contactTypes.groups) return false }, SEARCH_DEBOUNCE_MS)
if (c.type === 'official' && !contactTypes.officials) return false return () => window.clearTimeout(timer)
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false }, [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 return true
}) })
// 关键词过滤 if (debouncedSearchKeyword) {
if (searchKeyword.trim()) { filtered = filtered.filter(contact =>
const lower = searchKeyword.toLowerCase() contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) ||
filtered = filtered.filter(c => contact.remark?.toLowerCase().includes(debouncedSearchKeyword) ||
c.displayName?.toLowerCase().includes(lower) || contact.username.toLowerCase().includes(debouncedSearchKeyword)
c.remark?.toLowerCase().includes(lower) ||
c.username.toLowerCase().includes(lower)
) )
} }
setFilteredContacts(filtered) return filtered
}, [searchKeyword, contacts, contactTypes]) }, [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(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node const target = event.target as Node
@@ -123,11 +247,35 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect]) }, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => { const selectedInFilteredCount = useMemo(() => {
return selectedUsernames.has(contact.username) ? count + 1 : count return filteredContacts.reduce((count, contact) => {
}, 0) return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
}, [filteredContacts, selectedUsernames])
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length 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<HTMLDivElement>) => {
setScrollTop(event.currentTarget.scrollTop)
}, [])
const toggleContactSelected = (username: string, checked: boolean) => { const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => { setSelectedUsernames(prev => {
const next = new Set(prev) const next = new Set(prev)
@@ -297,7 +445,12 @@ function ContactsPage() {
</div> </div>
<div className="contacts-count"> <div className="contacts-count">
{filteredContacts.length} {filteredContacts.length} / {contacts.length}
{avatarEnrichProgress.running && (
<span className="avatar-enrich-progress">
{avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
</span>
)}
</div> </div>
{exportMode && ( {exportMode && (
@@ -315,61 +468,73 @@ function ContactsPage() {
</div> </div>
)} )}
{isLoading ? ( {isLoading && contacts.length === 0 ? (
<div className="loading-state"> <div className="loading-state">
<Loader2 size={32} className="spin" /> <Loader2 size={32} className="spin" />
<span>...</span> <span>...</span>
</div> </div>
) : filteredContacts.length === 0 ? ( ) : filteredContacts.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
<span></span> <span></span>
</div> </div>
) : ( ) : (
<div className="contacts-list"> <div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
{filteredContacts.map(contact => { <div
className="contacts-list-virtual"
style={{ height: filteredContacts.length * VIRTUAL_ROW_HEIGHT }}
>
{visibleContacts.map((contact, idx) => {
const absoluteIndex = startIndex + idx
const top = absoluteIndex * VIRTUAL_ROW_HEIGHT
const isChecked = selectedUsernames.has(contact.username) const isChecked = selectedUsernames.has(contact.username)
const isActive = !exportMode && selectedContact?.username === contact.username const isActive = !exportMode && selectedContact?.username === contact.username
return ( return (
<div <div
key={contact.username} key={contact.username}
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`} className="contact-row"
onClick={() => { style={{ transform: `translateY(${top}px)` }}
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
> >
{exportMode && ( <div
<label className="contact-select" onClick={e => e.stopPropagation()}> className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
<input onClick={() => {
type="checkbox" if (exportMode) {
checked={isChecked} toggleContactSelected(contact.username, !isChecked)
onChange={e => toggleContactSelected(contact.username, e.target.checked)} } else {
/> setSelectedContact(isActive ? null : contact)
</label> }
)} }}
<div className="contact-avatar"> >
{contact.avatarUrl ? ( {exportMode && (
<img src={contact.avatarUrl} alt="" /> <label className="contact-select" onClick={e => e.stopPropagation()}>
) : ( <input
<span>{getAvatarLetter(contact.displayName)}</span> type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)} )}
</div> <div className="contact-avatar">
<div className="contact-info"> {contact.avatarUrl ? (
<div className="contact-name">{contact.displayName}</div> <img src={contact.avatarUrl} alt="" loading="lazy" />
{contact.remark && contact.remark !== contact.displayName && ( ) : (
<div className="contact-remark">: {contact.remark}</div> <span>{getAvatarLetter(contact.displayName)}</span>
)} )}
</div> </div>
<div className={`contact-type ${contact.type}`}> <div className="contact-info">
{getContactTypeIcon(contact.type)} <div className="contact-name">{contact.displayName}</div>
<span>{getContactTypeName(contact.type)}</span> {contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div> </div>
</div> </div>
) )
})} })}
</div>
</div> </div>
)} )}
</div> </div>