mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
perf(contacts): speed up directory loading and smooth list rendering
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<ContactInfo[]>([])
|
||||
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
|
||||
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(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<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 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<HTMLDivElement>) => {
|
||||
setScrollTop(event.currentTarget.scrollTop)
|
||||
}, [])
|
||||
|
||||
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||
setSelectedUsernames(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -297,7 +445,12 @@ function ContactsPage() {
|
||||
</div>
|
||||
|
||||
<div className="contacts-count">
|
||||
共 {filteredContacts.length} 个联系人
|
||||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
||||
{avatarEnrichProgress.running && (
|
||||
<span className="avatar-enrich-progress">
|
||||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{exportMode && (
|
||||
@@ -315,61 +468,73 @@ function ContactsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
{isLoading && contacts.length === 0 ? (
|
||||
<div className="loading-state">
|
||||
<Loader2 size={32} className="spin" />
|
||||
<span>加载中...</span>
|
||||
<span>联系人加载中...</span>
|
||||
</div>
|
||||
) : filteredContacts.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<span>暂无联系人</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="contacts-list">
|
||||
{filteredContacts.map(contact => {
|
||||
<div className="contacts-list" ref={listRef} onScroll={onContactsListScroll}>
|
||||
<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 isActive = !exportMode && selectedContact?.username === contact.username
|
||||
return (
|
||||
<div
|
||||
key={contact.username}
|
||||
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (exportMode) {
|
||||
toggleContactSelected(contact.username, !isChecked)
|
||||
} else {
|
||||
setSelectedContact(isActive ? null : contact)
|
||||
}
|
||||
}}
|
||||
className="contact-row"
|
||||
style={{ transform: `translateY(${top}px)` }}
|
||||
>
|
||||
{exportMode && (
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt="" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||
<div
|
||||
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
if (exportMode) {
|
||||
toggleContactSelected(contact.username, !isChecked)
|
||||
} else {
|
||||
setSelectedContact(isActive ? null : contact)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{exportMode && (
|
||||
<label className="contact-select" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
<div className="contact-name">{contact.displayName}</div>
|
||||
{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 className="contact-avatar">
|
||||
{contact.avatarUrl ? (
|
||||
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||
) : (
|
||||
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
<div className="contact-name">{contact.displayName}</div>
|
||||
{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>
|
||||
|
||||
Reference in New Issue
Block a user