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' import './ContactsPage.scss' interface ContactInfo { username: string displayName: string remark?: string nickname?: string avatarUrl?: string 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 [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, officials: false, deletedFriends: false }) // 导出模式与查看详情 const [exportMode, setExportMode] = useState(false) const [selectedContact, setSelectedContact] = useState(null) const navigate = useNavigate() const { setCurrentSession } = useChatStore() // 导出相关状态 const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json') const [exportAvatars, setExportAvatars] = useState(true) const [exportFolder, setExportFolder] = useState('') 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 contactsResult = await window.electronAPI.chat.getContacts() if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { setContacts(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 { if (loadVersionRef.current === loadVersion) { setIsLoading(false) } } }, [enrichContactsInBackground]) useEffect(() => { loadContacts() }, [loadContacts]) useEffect(() => { return () => { loadVersionRef.current += 1 } }, []) 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 (debouncedSearchKeyword) { filtered = filtered.filter(contact => contact.displayName?.toLowerCase().includes(debouncedSearchKeyword) || contact.remark?.toLowerCase().includes(debouncedSearchKeyword) || contact.username.toLowerCase().includes(debouncedSearchKeyword) ) } 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 if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) { setShowFormatSelect(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [showFormatSelect]) 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) if (checked) { next.add(username) } else { next.delete(username) } return next }) } const toggleAllFilteredSelected = (checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) filteredContacts.forEach(contact => { if (checked) { next.add(contact.username) } else { next.delete(contact.username) } }) return next }) } const getAvatarLetter = (name: string) => { if (!name) return '?' return [...name][0] || '?' } const getContactTypeIcon = (type: string) => { switch (type) { case 'friend': return case 'group': return case 'official': return case 'former_friend': return default: return } } const getContactTypeName = (type: string) => { switch (type) { case 'friend': return '好友' case 'group': return '群聊' case 'official': return '公众号' case 'former_friend': return '曾经的好友' default: return '其他' } } // 选择导出文件夹 const selectExportFolder = async () => { try { const result = await window.electronAPI.dialog.openDirectory({ title: '选择导出位置' }) if (result && !result.canceled && result.filePaths && result.filePaths.length > 0) { setExportFolder(result.filePaths[0]) } } catch (e) { console.error('选择文件夹失败:', e) } } // 开始导出 const startExport = async () => { if (!exportFolder) { alert('请先选择导出位置') return } if (selectedUsernames.size === 0) { alert('请至少选择一个联系人') return } setIsExporting(true) try { const exportOptions = { format: exportFormat, exportAvatars, contactTypes: { friends: contactTypes.friends, groups: contactTypes.groups, officials: contactTypes.officials }, selectedUsernames: Array.from(selectedUsernames) } const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions) if (result.success) { alert(`导出成功!共导出 ${result.successCount} 个联系人`) } else { alert(`导出失败:${result.error}`) } } catch (e) { console.error('导出失败:', e) alert(`导出失败:${String(e)}`) } finally { setIsExporting(false) } } const exportFormatOptions = [ { value: 'json', label: 'JSON', desc: '详细格式,包含完整联系人信息' }, { value: 'csv', label: 'CSV (Excel)', desc: '电子表格格式,适合Excel查看' }, { value: 'vcf', label: 'VCF (vCard)', desc: '标准名片格式,支持导入手机' } ] const getOptionLabel = (value: string) => { return exportFormatOptions.find(opt => opt.value === value)?.label || value } return (
{/* 左侧:联系人列表 */}

通讯录

setSearchKeyword(e.target.value)} /> {searchKeyword && ( )}
共 {filteredContacts.length} / {contacts.length} 个联系人 {avatarEnrichProgress.running && ( 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} )}
{exportMode && (
已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})
)} {isLoading && contacts.length === 0 ? (
联系人加载中...
) : filteredContacts.length === 0 ? (
暂无联系人
) : (
{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) } }} > {exportMode && ( )}
{contact.avatarUrl ? ( ) : ( {getAvatarLetter(contact.displayName)} )}
{contact.displayName}
{contact.remark && contact.remark !== contact.displayName && (
备注: {contact.remark}
)}
{getContactTypeIcon(contact.type)} {getContactTypeName(contact.type)}
) })}
)}
{/* 右侧面板 */} {exportMode ? (

导出设置

导出格式

{showFormatSelect && (
{exportFormatOptions.map(option => ( ))}
)}

导出选项

导出位置

{exportFolder || '未设置'}
) : selectedContact ? (

联系人详情

{selectedContact.avatarUrl ? ( ) : ( {getAvatarLetter(selectedContact.displayName)} )}
{selectedContact.displayName}
{getContactTypeIcon(selectedContact.type)} {getContactTypeName(selectedContact.type)}
用户名{selectedContact.username}
昵称{selectedContact.nickname || selectedContact.displayName}
{selectedContact.remark &&
备注{selectedContact.remark}
}
类型{getContactTypeName(selectedContact.type)}
) : (
点击左侧联系人查看详情
)}
) } export default ContactsPage