mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
perf(contacts): speed up directory loading and smooth list rendering
This commit is contained in:
@@ -684,22 +684,37 @@ 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())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (normalizedUsernames.length === 0) return result
|
||||||
|
|
||||||
|
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(
|
const queryResult = await wcdbService.execQuery(
|
||||||
'media',
|
'media',
|
||||||
headImageDbPath,
|
headImageDbPath,
|
||||||
`SELECT image_buffer FROM head_image WHERE username = '${escapedUsername}' LIMIT 1`
|
`SELECT username, image_buffer FROM head_image WHERE username IN (${usernamesExpr})`
|
||||||
)
|
)
|
||||||
|
|
||||||
if (queryResult.success && queryResult.rows && queryResult.rows.length > 0) {
|
if (!queryResult.success || !queryResult.rows || queryResult.rows.length === 0) {
|
||||||
const row = queryResult.rows[0] as any
|
continue
|
||||||
if (row?.image_buffer) {
|
}
|
||||||
let base64Data: string
|
|
||||||
|
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') {
|
if (typeof row.image_buffer === 'string') {
|
||||||
// WCDB 返回的 BLOB 是十六进制字符串,需要转换为 base64
|
// WCDB 返回的 BLOB 可能是十六进制字符串,需要转换为 base64
|
||||||
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
if (row.image_buffer.toLowerCase().startsWith('ffd8')) {
|
||||||
const buffer = Buffer.from(row.image_buffer, 'hex')
|
const buffer = Buffer.from(row.image_buffer, 'hex')
|
||||||
base64Data = buffer.toString('base64')
|
base64Data = buffer.toString('base64')
|
||||||
@@ -710,15 +725,12 @@ class ChatService {
|
|||||||
base64Data = row.image_buffer.toString('base64')
|
base64Data = row.image_buffer.toString('base64')
|
||||||
} else if (Array.isArray(row.image_buffer)) {
|
} else if (Array.isArray(row.image_buffer)) {
|
||||||
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
base64Data = Buffer.from(row.image_buffer).toString('base64')
|
||||||
} else {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (base64Data) {
|
||||||
result[username] = `data:image/jpeg;base64,${base64Data}`
|
result[username] = `data:image/jpeg;base64,${base64Data}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// 静默处理单个用户的错误
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('从 head_image.db 获取头像失败:', e)
|
console.error('从 head_image.db 获取头像失败:', 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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (loadVersionRef.current === loadVersion) {
|
||||||
setIsLoading(false)
|
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 filteredContacts.reduce((count, contact) => {
|
||||||
return selectedUsernames.has(contact.username) ? count + 1 : count
|
return selectedUsernames.has(contact.username) ? count + 1 : count
|
||||||
}, 0)
|
}, 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,23 +468,33 @@ 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-row"
|
||||||
|
style={{ transform: `translateY(${top}px)` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (exportMode) {
|
if (exportMode) {
|
||||||
@@ -352,7 +515,7 @@ function ContactsPage() {
|
|||||||
)}
|
)}
|
||||||
<div className="contact-avatar">
|
<div className="contact-avatar">
|
||||||
{contact.avatarUrl ? (
|
{contact.avatarUrl ? (
|
||||||
<img src={contact.avatarUrl} alt="" />
|
<img src={contact.avatarUrl} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<span>{getAvatarLetter(contact.displayName)}</span>
|
<span>{getAvatarLetter(contact.displayName)}</span>
|
||||||
)}
|
)}
|
||||||
@@ -368,9 +531,11 @@ function ContactsPage() {
|
|||||||
<span>{getContactTypeName(contact.type)}</span>
|
<span>{getContactTypeName(contact.type)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user