mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
1184 lines
53 KiB
TypeScript
1184 lines
53 KiB
TypeScript
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, AlertTriangle, ClipboardList, Aperture } from 'lucide-react'
|
||
import { useChatStore } from '../stores/chatStore'
|
||
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||
import * as configService from '../services/config'
|
||
import type { ContactInfo } from '../types/models'
|
||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||
import './ContactsPage.scss'
|
||
|
||
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
|
||
const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000
|
||
const AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
|
||
|
||
interface ContactsLoadSession {
|
||
requestId: string
|
||
startedAt: number
|
||
attempt: number
|
||
timeoutMs: number
|
||
}
|
||
|
||
interface ContactsLoadIssue {
|
||
kind: 'timeout' | 'error'
|
||
title: string
|
||
message: string
|
||
reason: string
|
||
errorDetail?: string
|
||
occurredAt: number
|
||
elapsedMs: number
|
||
}
|
||
|
||
type ContactsDataSource = 'cache' | 'network' | null
|
||
|
||
function ContactsPage() {
|
||
const [contacts, setContacts] = 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,
|
||
officials: false,
|
||
deletedFriends: false
|
||
})
|
||
|
||
// 导出模式与查看详情
|
||
const [exportMode, setExportMode] = useState(false)
|
||
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
|
||
const [snsUserPostCounts, setSnsUserPostCounts] = useState<Record<string, number>>({})
|
||
const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle')
|
||
const [snsTimelineTarget, setSnsTimelineTarget] = useState<ContactSnsTimelineTarget | null>(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<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 sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
||
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
|
||
const loadAttemptRef = useRef(0)
|
||
const loadTimeoutTimerRef = useRef<number | null>(null)
|
||
const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||
const [loadSession, setLoadSession] = useState<ContactsLoadSession | null>(null)
|
||
const [loadIssue, setLoadIssue] = useState<ContactsLoadIssue | null>(null)
|
||
const [showDiagnostics, setShowDiagnostics] = useState(false)
|
||
const [diagnosticTick, setDiagnosticTick] = useState(Date.now())
|
||
const [contactsDataSource, setContactsDataSource] = useState<ContactsDataSource>(null)
|
||
const [contactsUpdatedAt, setContactsUpdatedAt] = useState<number | null>(null)
|
||
const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState<number | null>(null)
|
||
const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS)
|
||
const contactsCacheScopeRef = useRef('default')
|
||
const contactsAvatarCacheRef = useRef<Record<string, configService.ContactsAvatarCacheEntry>>({})
|
||
|
||
const ensureContactsCacheScope = useCallback(async () => {
|
||
if (contactsCacheScopeRef.current !== 'default') {
|
||
return contactsCacheScopeRef.current
|
||
}
|
||
const [dbPath, myWxid] = await Promise.all([
|
||
configService.getDbPath(),
|
||
configService.getMyWxid()
|
||
])
|
||
const scopeKey = dbPath || myWxid
|
||
? `${dbPath || ''}::${myWxid || ''}`
|
||
: 'default'
|
||
contactsCacheScopeRef.current = scopeKey
|
||
return scopeKey
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
void (async () => {
|
||
try {
|
||
const value = await configService.getContactsLoadTimeoutMs()
|
||
if (!cancelled) {
|
||
setContactsLoadTimeoutMs(value)
|
||
}
|
||
} catch (error) {
|
||
console.error('读取通讯录超时配置失败:', error)
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs
|
||
}, [contactsLoadTimeoutMs])
|
||
|
||
const mergeAvatarCacheIntoContacts = useCallback((sourceContacts: ContactInfo[]): ContactInfo[] => {
|
||
const avatarCache = contactsAvatarCacheRef.current
|
||
if (!sourceContacts.length || Object.keys(avatarCache).length === 0) {
|
||
return sourceContacts
|
||
}
|
||
let changed = false
|
||
const merged = sourceContacts.map((contact) => {
|
||
const cachedAvatar = avatarCache[contact.username]?.avatarUrl
|
||
if (!cachedAvatar || contact.avatarUrl) {
|
||
return contact
|
||
}
|
||
changed = true
|
||
return {
|
||
...contact,
|
||
avatarUrl: cachedAvatar
|
||
}
|
||
})
|
||
return changed ? merged : sourceContacts
|
||
}, [])
|
||
|
||
const upsertAvatarCacheFromContacts = useCallback((
|
||
scopeKey: string,
|
||
sourceContacts: ContactInfo[],
|
||
options?: { prune?: boolean; markCheckedUsernames?: string[] }
|
||
) => {
|
||
if (!scopeKey) return
|
||
const nextCache = { ...contactsAvatarCacheRef.current }
|
||
const now = Date.now()
|
||
const markCheckedSet = new Set((options?.markCheckedUsernames || []).filter(Boolean))
|
||
const usernamesInSource = new Set<string>()
|
||
let changed = false
|
||
|
||
for (const contact of sourceContacts) {
|
||
const username = String(contact.username || '').trim()
|
||
if (!username) continue
|
||
usernamesInSource.add(username)
|
||
const prev = nextCache[username]
|
||
const avatarUrl = String(contact.avatarUrl || '').trim()
|
||
if (!avatarUrl) continue
|
||
const updatedAt = !prev || prev.avatarUrl !== avatarUrl ? now : prev.updatedAt
|
||
const checkedAt = markCheckedSet.has(username) ? now : (prev?.checkedAt || now)
|
||
if (!prev || prev.avatarUrl !== avatarUrl || prev.updatedAt !== updatedAt || prev.checkedAt !== checkedAt) {
|
||
nextCache[username] = {
|
||
avatarUrl,
|
||
updatedAt,
|
||
checkedAt
|
||
}
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
for (const username of markCheckedSet) {
|
||
const prev = nextCache[username]
|
||
if (!prev) continue
|
||
if (prev.checkedAt !== now) {
|
||
nextCache[username] = {
|
||
...prev,
|
||
checkedAt: now
|
||
}
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
if (options?.prune) {
|
||
for (const username of Object.keys(nextCache)) {
|
||
if (usernamesInSource.has(username)) continue
|
||
delete nextCache[username]
|
||
changed = true
|
||
}
|
||
}
|
||
|
||
if (!changed) return
|
||
contactsAvatarCacheRef.current = nextCache
|
||
setAvatarCacheUpdatedAt(now)
|
||
void configService.setContactsAvatarCache(scopeKey, nextCache).catch((error) => {
|
||
console.error('写入通讯录头像缓存失败:', error)
|
||
})
|
||
}, [])
|
||
|
||
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,
|
||
scopeKey: string
|
||
) => {
|
||
const sourceByUsername = new Map<string, ContactInfo>()
|
||
for (const contact of sourceContacts) {
|
||
if (!contact.username) continue
|
||
sourceByUsername.set(contact.username, contact)
|
||
}
|
||
const now = Date.now()
|
||
const usernames = sourceContacts
|
||
.map(contact => contact.username)
|
||
.filter(Boolean)
|
||
.filter((username) => {
|
||
const currentContact = sourceByUsername.get(username)
|
||
if (!currentContact) return false
|
||
const cacheEntry = contactsAvatarCacheRef.current[username]
|
||
if (!cacheEntry || !cacheEntry.avatarUrl) {
|
||
return !currentContact.avatarUrl
|
||
}
|
||
if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) {
|
||
return true
|
||
}
|
||
const checkedAt = cacheEntry.checkedAt || 0
|
||
return now - checkedAt >= AVATAR_RECHECK_INTERVAL_MS
|
||
})
|
||
|
||
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)
|
||
for (const [username, enriched] of Object.entries(avatarResult.contacts)) {
|
||
const prev = sourceByUsername.get(username)
|
||
if (!prev) continue
|
||
sourceByUsername.set(username, {
|
||
...prev,
|
||
displayName: enriched.displayName || prev.displayName,
|
||
avatarUrl: enriched.avatarUrl || prev.avatarUrl
|
||
})
|
||
}
|
||
}
|
||
const batchContacts = batch
|
||
.map(username => sourceByUsername.get(username))
|
||
.filter((contact): contact is ContactInfo => Boolean(contact))
|
||
upsertAvatarCacheFromContacts(scopeKey, batchContacts, {
|
||
markCheckedUsernames: batch
|
||
})
|
||
} 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, upsertAvatarCacheFromContacts])
|
||
|
||
// 加载通讯录
|
||
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
|
||
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
|
||
const loadVersion = loadVersionRef.current + 1
|
||
loadVersionRef.current = loadVersion
|
||
loadAttemptRef.current += 1
|
||
const startedAt = Date.now()
|
||
const timeoutMs = contactsLoadTimeoutMsRef.current
|
||
const requestId = `contacts-${startedAt}-${loadAttemptRef.current}`
|
||
setLoadSession({
|
||
requestId,
|
||
startedAt,
|
||
attempt: loadAttemptRef.current,
|
||
timeoutMs
|
||
})
|
||
setLoadIssue(null)
|
||
setShowDiagnostics(false)
|
||
if (loadTimeoutTimerRef.current) {
|
||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||
loadTimeoutTimerRef.current = null
|
||
}
|
||
const timeoutTimerId = window.setTimeout(() => {
|
||
if (loadVersionRef.current !== loadVersion) return
|
||
const elapsedMs = Date.now() - startedAt
|
||
setLoadIssue({
|
||
kind: 'timeout',
|
||
title: '通讯录加载超时',
|
||
message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`,
|
||
reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。',
|
||
occurredAt: Date.now(),
|
||
elapsedMs
|
||
})
|
||
}, timeoutMs)
|
||
loadTimeoutTimerRef.current = timeoutTimerId
|
||
|
||
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) {
|
||
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||
loadTimeoutTimerRef.current = null
|
||
}
|
||
const contactsWithAvatarCache = mergeAvatarCacheIntoContacts(contactsResult.contacts)
|
||
setContacts(contactsWithAvatarCache)
|
||
syncContactTypeCounts(contactsWithAvatarCache)
|
||
setSelectedUsernames(new Set())
|
||
setSelectedContact(prev => {
|
||
if (!prev) return prev
|
||
return contactsWithAvatarCache.find(contact => contact.username === prev.username) || null
|
||
})
|
||
const now = Date.now()
|
||
setContactsDataSource('network')
|
||
setContactsUpdatedAt(now)
|
||
setLoadIssue(null)
|
||
setIsLoading(false)
|
||
upsertAvatarCacheFromContacts(scopeKey, contactsWithAvatarCache, { prune: true })
|
||
void configService.setContactsListCache(
|
||
scopeKey,
|
||
contactsWithAvatarCache.map(contact => ({
|
||
username: contact.username,
|
||
displayName: contact.displayName,
|
||
remark: contact.remark,
|
||
nickname: contact.nickname,
|
||
type: contact.type
|
||
}))
|
||
).catch((error) => {
|
||
console.error('写入通讯录缓存失败:', error)
|
||
})
|
||
void enrichContactsInBackground(contactsWithAvatarCache, loadVersion, scopeKey)
|
||
return
|
||
}
|
||
const elapsedMs = Date.now() - startedAt
|
||
setLoadIssue({
|
||
kind: 'error',
|
||
title: '通讯录加载失败',
|
||
message: '联系人接口返回失败,未拿到联系人列表。',
|
||
reason: 'chat.getContacts 返回 success=false。',
|
||
errorDetail: contactsResult.error || '未知错误',
|
||
occurredAt: Date.now(),
|
||
elapsedMs
|
||
})
|
||
} catch (e) {
|
||
console.error('加载通讯录失败:', e)
|
||
const elapsedMs = Date.now() - startedAt
|
||
setLoadIssue({
|
||
kind: 'error',
|
||
title: '通讯录加载失败',
|
||
message: '联系人请求执行异常。',
|
||
reason: '调用 chat.getContacts 发生异常。',
|
||
errorDetail: String(e),
|
||
occurredAt: Date.now(),
|
||
elapsedMs
|
||
})
|
||
} finally {
|
||
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||
loadTimeoutTimerRef.current = null
|
||
}
|
||
if (loadVersionRef.current === loadVersion) {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
}, [
|
||
ensureContactsCacheScope,
|
||
enrichContactsInBackground,
|
||
mergeAvatarCacheIntoContacts,
|
||
syncContactTypeCounts,
|
||
upsertAvatarCacheFromContacts
|
||
])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
void (async () => {
|
||
const scopeKey = await ensureContactsCacheScope()
|
||
if (cancelled) return
|
||
try {
|
||
const [cacheItem, avatarCacheItem] = await Promise.all([
|
||
configService.getContactsListCache(scopeKey),
|
||
configService.getContactsAvatarCache(scopeKey)
|
||
])
|
||
const avatarCacheMap = avatarCacheItem?.avatars || {}
|
||
contactsAvatarCacheRef.current = avatarCacheMap
|
||
setAvatarCacheUpdatedAt(avatarCacheItem?.updatedAt || null)
|
||
if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) {
|
||
const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({
|
||
...contact,
|
||
avatarUrl: avatarCacheMap[contact.username]?.avatarUrl
|
||
}))
|
||
setContacts(cachedContacts)
|
||
syncContactTypeCounts(cachedContacts)
|
||
setContactsDataSource('cache')
|
||
setContactsUpdatedAt(cacheItem.updatedAt || null)
|
||
setIsLoading(false)
|
||
}
|
||
} catch (error) {
|
||
console.error('读取通讯录缓存失败:', error)
|
||
}
|
||
if (!cancelled) {
|
||
void loadContacts({ scopeKey })
|
||
}
|
||
})()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [ensureContactsCacheScope, loadContacts, syncContactTypeCounts])
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
if (loadTimeoutTimerRef.current) {
|
||
window.clearTimeout(loadTimeoutTimerRef.current)
|
||
loadTimeoutTimerRef.current = null
|
||
}
|
||
loadVersionRef.current += 1
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!loadIssue || contacts.length > 0) return
|
||
if (!(isLoading && loadIssue.kind === 'timeout')) return
|
||
const timer = window.setInterval(() => {
|
||
setDiagnosticTick(Date.now())
|
||
}, 500)
|
||
return () => window.clearInterval(timer)
|
||
}, [contacts.length, isLoading, loadIssue])
|
||
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => {
|
||
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
|
||
}, SEARCH_DEBOUNCE_MS)
|
||
return () => window.clearTimeout(timer)
|
||
}, [searchKeyword])
|
||
|
||
const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => {
|
||
if (!options?.force && (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'ready')) {
|
||
return
|
||
}
|
||
|
||
setSnsUserPostCountsStatus('loading')
|
||
try {
|
||
const result = await window.electronAPI.sns.getUserPostCounts()
|
||
if (!result.success || !result.counts) {
|
||
setSnsUserPostCountsStatus('error')
|
||
return
|
||
}
|
||
|
||
const normalizedCounts: Record<string, number> = {}
|
||
for (const [rawUsername, rawCount] of Object.entries(result.counts)) {
|
||
const username = String(rawUsername || '').trim()
|
||
if (!username) continue
|
||
const value = Number(rawCount)
|
||
normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
||
}
|
||
|
||
setSnsUserPostCounts(normalizedCounts)
|
||
setSnsUserPostCountsStatus('ready')
|
||
} catch (error) {
|
||
console.error('加载通讯录联系人朋友圈条数失败:', error)
|
||
setSnsUserPostCountsStatus('error')
|
||
}
|
||
}, [snsUserPostCountsStatus])
|
||
|
||
useEffect(() => {
|
||
if (!selectedContact || !isSingleContactSession(selectedContact.username)) return
|
||
if (snsUserPostCountsStatus !== 'idle') return
|
||
void loadSnsUserPostCounts()
|
||
}, [loadSnsUserPostCounts, selectedContact, snsUserPostCountsStatus])
|
||
|
||
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])
|
||
|
||
const contactTypeCounts = useMemo(() => toContactTypeCardCounts(sharedTabCounts), [sharedTabCounts])
|
||
|
||
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 selectedContactSupportsSns = useMemo(() => {
|
||
return Boolean(selectedContact && isSingleContactSession(selectedContact.username))
|
||
}, [selectedContact])
|
||
|
||
const selectedContactSnsCount = useMemo(() => {
|
||
if (!selectedContactSupportsSns || !selectedContact) return null
|
||
if (snsUserPostCountsStatus !== 'ready') return null
|
||
const rawCount = Number(snsUserPostCounts[selectedContact.username] || 0)
|
||
return Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
|
||
}, [selectedContact, selectedContactSupportsSns, snsUserPostCounts, snsUserPostCountsStatus])
|
||
|
||
const selectedContactSnsEntryLabel = useMemo(() => {
|
||
if (!selectedContactSupportsSns) return ''
|
||
if (selectedContactSnsCount !== null) {
|
||
return `朋友圈:${selectedContactSnsCount.toLocaleString('zh-CN')}条`
|
||
}
|
||
if (snsUserPostCountsStatus === 'error') return '朋友圈:查看'
|
||
return '朋友圈:统计中...'
|
||
}, [selectedContactSupportsSns, selectedContactSnsCount, snsUserPostCountsStatus])
|
||
|
||
const openSelectedContactSnsTimeline = useCallback(() => {
|
||
if (!selectedContact || !selectedContactSupportsSns) return
|
||
if (snsUserPostCountsStatus === 'idle') {
|
||
void loadSnsUserPostCounts()
|
||
}
|
||
setSnsTimelineTarget({
|
||
username: selectedContact.username,
|
||
displayName: selectedContact.displayName || selectedContact.remark || selectedContact.nickname || selectedContact.username,
|
||
avatarUrl: selectedContact.avatarUrl
|
||
})
|
||
}, [loadSnsUserPostCounts, selectedContact, selectedContactSupportsSns, snsUserPostCountsStatus])
|
||
|
||
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 issueElapsedMs = useMemo(() => {
|
||
if (!loadIssue) return 0
|
||
if (isLoading && loadSession) {
|
||
return Math.max(loadIssue.elapsedMs, diagnosticTick - loadSession.startedAt)
|
||
}
|
||
return loadIssue.elapsedMs
|
||
}, [diagnosticTick, isLoading, loadIssue, loadSession])
|
||
|
||
const diagnosticsText = useMemo(() => {
|
||
if (!loadIssue || !loadSession) return ''
|
||
return [
|
||
`请求ID: ${loadSession.requestId}`,
|
||
`请求序号: 第 ${loadSession.attempt} 次`,
|
||
`阈值配置: ${loadSession.timeoutMs}ms`,
|
||
`当前状态: ${loadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`,
|
||
`累计耗时: ${(issueElapsedMs / 1000).toFixed(1)}s`,
|
||
`发生时间: ${new Date(loadIssue.occurredAt).toLocaleString()}`,
|
||
`阶段: chat.getContacts`,
|
||
`原因: ${loadIssue.reason}`,
|
||
`错误详情: ${loadIssue.errorDetail || '无'}`
|
||
].join('\n')
|
||
}, [issueElapsedMs, loadIssue, loadSession])
|
||
|
||
const copyDiagnostics = useCallback(async () => {
|
||
if (!diagnosticsText) return
|
||
try {
|
||
await navigator.clipboard.writeText(diagnosticsText)
|
||
alert('诊断信息已复制')
|
||
} catch (error) {
|
||
console.error('复制诊断信息失败:', error)
|
||
alert('复制失败,请手动复制诊断信息')
|
||
}
|
||
}, [diagnosticsText])
|
||
|
||
const contactsUpdatedAtLabel = useMemo(() => {
|
||
if (!contactsUpdatedAt) return ''
|
||
return new Date(contactsUpdatedAt).toLocaleString()
|
||
}, [contactsUpdatedAt])
|
||
|
||
const avatarCachedCount = useMemo(() => {
|
||
return contacts.reduce((count, contact) => (
|
||
contact.avatarUrl ? count + 1 : count
|
||
), 0)
|
||
}, [contacts])
|
||
|
||
const avatarCacheUpdatedAtLabel = useMemo(() => {
|
||
if (!avatarCacheUpdatedAt) return ''
|
||
return new Date(avatarCacheUpdatedAt).toLocaleString()
|
||
}, [avatarCacheUpdatedAt])
|
||
|
||
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 <User size={14} />
|
||
case 'group': return <Users size={14} />
|
||
case 'official': return <MessageSquare size={14} />
|
||
case 'former_friend': return <UserX size={14} />
|
||
default: return <User size={14} />
|
||
}
|
||
}
|
||
|
||
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 (
|
||
<div className="contacts-page">
|
||
{/* 左侧:联系人列表 */}
|
||
<div className="contacts-panel">
|
||
<div className="panel-header">
|
||
<h2>通讯录</h2>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<button
|
||
className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
|
||
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
|
||
title={exportMode ? '退出导出模式' : '进入导出模式'}
|
||
>
|
||
<Download size={18} />
|
||
</button>
|
||
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
|
||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="search-bar">
|
||
<Search size={16} />
|
||
<input
|
||
type="text"
|
||
placeholder="搜索联系人..."
|
||
value={searchKeyword}
|
||
onChange={e => setSearchKeyword(e.target.value)}
|
||
/>
|
||
{searchKeyword && (
|
||
<button className="clear-btn" onClick={() => setSearchKeyword('')}>
|
||
<X size={14} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
<div className="type-filters">
|
||
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
|
||
<input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
|
||
<User size={16} />
|
||
<span className="chip-label">好友</span>
|
||
<span className="chip-count">{contactTypeCounts.friends}</span>
|
||
</label>
|
||
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
|
||
<input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
|
||
<Users size={16} />
|
||
<span className="chip-label">群聊</span>
|
||
<span className="chip-count">{contactTypeCounts.groups}</span>
|
||
</label>
|
||
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
|
||
<input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
|
||
<MessageSquare size={16} />
|
||
<span className="chip-label">公众号</span>
|
||
<span className="chip-count">{contactTypeCounts.officials}</span>
|
||
</label>
|
||
<label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
|
||
<input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
|
||
<UserX size={16} />
|
||
<span className="chip-label">曾经的好友</span>
|
||
<span className="chip-count">{contactTypeCounts.deletedFriends}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="contacts-count">
|
||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
||
{contactsUpdatedAt && (
|
||
<span className="contacts-cache-meta">
|
||
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
||
</span>
|
||
)}
|
||
{contacts.length > 0 && (
|
||
<span className="contacts-cache-meta">
|
||
头像缓存 {avatarCachedCount}/{contacts.length}
|
||
{avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''}
|
||
</span>
|
||
)}
|
||
{isLoading && contacts.length > 0 && (
|
||
<span className="contacts-cache-meta syncing">后台同步中...</span>
|
||
)}
|
||
{avatarEnrichProgress.running && (
|
||
<span className="avatar-enrich-progress">
|
||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{exportMode && (
|
||
<div className="selection-toolbar">
|
||
<label className="checkbox-item">
|
||
<input
|
||
type="checkbox"
|
||
checked={allFilteredSelected}
|
||
onChange={e => toggleAllFilteredSelected(e.target.checked)}
|
||
disabled={filteredContacts.length === 0}
|
||
/>
|
||
<span>全选当前筛选结果</span>
|
||
</label>
|
||
<span className="selection-count">已选 {selectedUsernames.size}(当前筛选 {selectedInFilteredCount} / {filteredContacts.length})</span>
|
||
</div>
|
||
)}
|
||
|
||
{contacts.length === 0 && loadIssue ? (
|
||
<div className="load-issue-state">
|
||
<div className="issue-card">
|
||
<div className="issue-title">
|
||
<AlertTriangle size={18} />
|
||
<span>{loadIssue.title}</span>
|
||
</div>
|
||
<p className="issue-message">{loadIssue.message}</p>
|
||
<p className="issue-reason">{loadIssue.reason}</p>
|
||
<ul className="issue-hints">
|
||
<li>可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。</li>
|
||
<li>可能原因2:contact.db 数据量较大,首次查询时间过长。</li>
|
||
<li>可能原因3:数据库连接状态异常或 IPC 调用卡住。</li>
|
||
</ul>
|
||
<div className="issue-actions">
|
||
<button className="issue-btn primary" onClick={() => void loadContacts()}>
|
||
<RefreshCw size={14} />
|
||
<span>重试加载</span>
|
||
</button>
|
||
<button className="issue-btn" onClick={() => setShowDiagnostics(prev => !prev)}>
|
||
<ClipboardList size={14} />
|
||
<span>{showDiagnostics ? '收起诊断详情' : '查看诊断详情'}</span>
|
||
</button>
|
||
<button className="issue-btn" onClick={copyDiagnostics}>
|
||
<span>复制诊断信息</span>
|
||
</button>
|
||
</div>
|
||
{showDiagnostics && (
|
||
<pre className="issue-diagnostics">{diagnosticsText}</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : isLoading && contacts.length === 0 ? (
|
||
<div className="loading-state">
|
||
<Loader2 size={32} className="spin" />
|
||
<span>联系人加载中...</span>
|
||
</div>
|
||
) : filteredContacts.length === 0 ? (
|
||
<div className="empty-state">
|
||
<span>暂无联系人</span>
|
||
</div>
|
||
) : (
|
||
<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-row"
|
||
style={{ transform: `translateY(${top}px)` }}
|
||
>
|
||
<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 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>
|
||
|
||
{/* 右侧面板 */}
|
||
{exportMode ? (
|
||
<div className="settings-panel">
|
||
<div className="panel-header">
|
||
<h2>导出设置</h2>
|
||
</div>
|
||
|
||
<div className="settings-content">
|
||
<div className="setting-section">
|
||
<h3>导出格式</h3>
|
||
<div className="format-select" ref={formatDropdownRef}>
|
||
<button
|
||
type="button"
|
||
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
|
||
onClick={() => setShowFormatSelect(!showFormatSelect)}
|
||
>
|
||
<span className="select-value">{getOptionLabel(exportFormat)}</span>
|
||
<ChevronDown size={16} />
|
||
</button>
|
||
{showFormatSelect && (
|
||
<div className="select-dropdown">
|
||
{exportFormatOptions.map(option => (
|
||
<button
|
||
key={option.value}
|
||
type="button"
|
||
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
|
||
onClick={() => {
|
||
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
|
||
setShowFormatSelect(false)
|
||
}}
|
||
>
|
||
<span className="option-label">{option.label}</span>
|
||
<span className="option-desc">{option.desc}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>导出选项</h3>
|
||
<label className="checkbox-item">
|
||
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
|
||
<span>导出头像</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="setting-section">
|
||
<h3>导出位置</h3>
|
||
<div className="export-path-display">
|
||
<FolderOpen size={16} />
|
||
<span>{exportFolder || '未设置'}</span>
|
||
</div>
|
||
<button className="select-folder-btn" onClick={selectExportFolder}>
|
||
<FolderOpen size={16} />
|
||
<span>选择导出目录</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="export-action">
|
||
<button
|
||
className="export-btn"
|
||
onClick={startExport}
|
||
disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
|
||
>
|
||
{isExporting ? (
|
||
<><Loader2 size={18} className="spin" /><span>导出中...</span></>
|
||
) : (
|
||
<><Download size={18} /><span>开始导出</span></>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : selectedContact ? (
|
||
<div className="settings-panel">
|
||
<div className="panel-header">
|
||
<h2>联系人详情</h2>
|
||
</div>
|
||
<div className="settings-content">
|
||
<div className="detail-profile">
|
||
<div className="detail-avatar">
|
||
{selectedContact.avatarUrl ? (
|
||
<img src={selectedContact.avatarUrl} alt="" />
|
||
) : (
|
||
<span>{getAvatarLetter(selectedContact.displayName)}</span>
|
||
)}
|
||
</div>
|
||
<div className="detail-name">{selectedContact.displayName}</div>
|
||
<div className={`contact-type ${selectedContact.type}`}>
|
||
{getContactTypeIcon(selectedContact.type)}
|
||
<span>{getContactTypeName(selectedContact.type)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="detail-info-list">
|
||
<div className="detail-row"><span className="detail-label">用户名</span><span className="detail-value">{selectedContact.username}</span></div>
|
||
<div className="detail-row"><span className="detail-label">昵称</span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
|
||
{selectedContact.remark && <div className="detail-row"><span className="detail-label">备注</span><span className="detail-value">{selectedContact.remark}</span></div>}
|
||
<div className="detail-row"><span className="detail-label">类型</span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
|
||
{selectedContactSupportsSns && (
|
||
<div className="detail-row">
|
||
<span className="detail-label">朋友圈</span>
|
||
<button
|
||
type="button"
|
||
className="detail-entry-btn"
|
||
onClick={openSelectedContactSnsTimeline}
|
||
>
|
||
<Aperture size={14} />
|
||
<span>{selectedContactSnsEntryLabel}</span>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<button
|
||
className="goto-chat-btn"
|
||
onClick={() => {
|
||
setCurrentSession(selectedContact.username)
|
||
navigate('/chat')
|
||
}}
|
||
>
|
||
<MessageCircle size={18} />
|
||
<span>查看聊天记录</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="settings-panel">
|
||
<div className="empty-detail">
|
||
<User size={48} />
|
||
<span>点击左侧联系人查看详情</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<ContactSnsTimelineDialog
|
||
target={snsTimelineTarget}
|
||
onClose={() => setSnsTimelineTarget(null)}
|
||
initialTotalPosts={selectedContact && snsTimelineTarget?.username === selectedContact.username ? selectedContactSnsCount : null}
|
||
initialTotalPostsLoading={selectedContact && snsTimelineTarget?.username === selectedContact.username
|
||
? snsUserPostCountsStatus === 'idle' || snsUserPostCountsStatus === 'loading'
|
||
: false}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default ContactsPage
|