diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 49efd28..a33df95 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -935,15 +935,15 @@ .format-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 8px; + gap: 6px; } .format-card { width: 100%; - min-height: 82px; + min-height: 0; border: 1px solid var(--border-color); border-radius: 10px; - padding: 10px; + padding: 8px 10px; text-align: left; background: var(--bg-primary); cursor: pointer; @@ -956,13 +956,14 @@ font-size: 13px; font-weight: 600; color: var(--text-primary); + line-height: 1.35; } .format-desc { - margin-top: 3px; + margin-top: 1px; font-size: 11px; color: var(--text-tertiary); - line-height: 1.4; + line-height: 1.35; } &.active { @@ -1031,10 +1032,22 @@ border: 1px solid var(--border-color); border-radius: 8px; padding: 8px; + width: 100%; display: flex; flex-direction: column; gap: 2px; background: var(--bg-primary); + text-align: left; + cursor: pointer; + color: inherit; + font: inherit; + appearance: none; + -webkit-appearance: none; + + &:focus-visible { + outline: 2px solid rgba(var(--primary-rgb), 0.35); + outline-offset: 1px; + } span { font-size: 12px; @@ -1048,11 +1061,6 @@ line-height: 1.4; } - input { - accent-color: var(--primary); - margin: 0 0 4px; - } - &.active { border-color: var(--primary); background: rgba(var(--primary-rgb), 0.08); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 1c4a27e..b87c021 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from 'react' import { useLocation } from 'react-router-dom' import { TableVirtuoso } from 'react-virtuoso' import { @@ -11,8 +11,11 @@ import { FolderOpen, Image as ImageIcon, Loader2, + AlertTriangle, + ClipboardList, MessageSquareText, Mic, + RefreshCw, Search, Square, Video, @@ -224,12 +227,48 @@ const getAvatarLetter = (name: string): string => { return [...name][0] || '?' } +const matchesContactTab = (contact: ContactInfo, tab: ConversationTab): boolean => { + if (tab === 'private') return contact.type === 'friend' + if (tab === 'group') return contact.type === 'group' + if (tab === 'official') return contact.type === 'official' + return contact.type === 'former_friend' +} + +const getContactTypeName = (type: ContactInfo['type']): string => { + if (type === 'friend') return '好友' + if (type === 'group') return '群聊' + if (type === 'official') return '公众号' + if (type === 'former_friend') return '曾经的好友' + return '其他' +} + const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 +const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 +const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 +const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 type SessionDataSource = 'cache' | 'network' | null +type ContactsDataSource = 'cache' | 'network' | null + +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 +} const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -480,6 +519,23 @@ function ExportPage() { const [searchKeyword, setSearchKeyword] = useState('') const [activeTab, setActiveTab] = useState('private') const [selectedSessions, setSelectedSessions] = useState>(new Set()) + const [contactsList, setContactsList] = useState([]) + const [isContactsListLoading, setIsContactsListLoading] = useState(true) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) + const [contactsListScrollTop, setContactsListScrollTop] = useState(0) + const [contactsListViewportHeight, setContactsListViewportHeight] = useState(480) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [contactsLoadSession, setContactsLoadSession] = useState(null) + const [contactsLoadIssue, setContactsLoadIssue] = useState(null) + const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) + const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) + const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({ + loaded: 0, + total: 0, + running: false + }) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') @@ -536,6 +592,12 @@ function ExportPage() { const preselectAppliedRef = useRef(false) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const contactsLoadVersionRef = useRef(0) + const contactsLoadAttemptRef = useRef(0) + const contactsLoadTimeoutTimerRef = useRef(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsAvatarCacheRef = useRef>({}) + const contactsListRef = useRef(null) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -564,6 +626,331 @@ function ExportPage() { } }, []) + 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 applyEnrichedContactsToList = useCallback((enrichedMap: Record) => { + if (!enrichedMap || Object.keys(enrichedMap).length === 0) return + setContactsList(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 + }) + }, []) + + const enrichContactsListInBackground = useCallback(async ( + sourceContacts: ContactInfo[], + loadVersion: number, + scopeKey: string + ) => { + const sourceByUsername = new Map() + 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 >= EXPORT_AVATAR_RECHECK_INTERVAL_MS + }) + + const total = usernames.length + setContactsAvatarEnrichProgress({ + loaded: 0, + total, + running: total > 0 + }) + if (total === 0) return + + for (let i = 0; i < total; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { + if (contactsLoadVersionRef.current !== loadVersion) return + const batch = usernames.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) + if (batch.length === 0) continue + + try { + const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + if (contactsLoadVersionRef.current !== loadVersion) return + if (avatarResult.success && avatarResult.contacts) { + applyEnrichedContactsToList(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)) + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + batchContacts, + { markCheckedUsernames: batch } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + } catch (error) { + console.error('导出页分批补全头像失败:', error) + } + + const loaded = Math.min(i + batch.length, total) + setContactsAvatarEnrichProgress({ + loaded, + total, + running: loaded < total + }) + await new Promise(resolve => setTimeout(resolve, 0)) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + }, [applyEnrichedContactsToList]) + + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { + const scopeKey = options?.scopeKey || await ensureExportCacheScope() + const loadVersion = contactsLoadVersionRef.current + 1 + contactsLoadVersionRef.current = loadVersion + contactsLoadAttemptRef.current += 1 + const startedAt = Date.now() + const timeoutMs = contactsLoadTimeoutMsRef.current + const requestId = `export-contacts-${startedAt}-${contactsLoadAttemptRef.current}` + setContactsLoadSession({ + requestId, + startedAt, + attempt: contactsLoadAttemptRef.current, + timeoutMs + }) + setContactsLoadIssue(null) + setShowContactsDiagnostics(false) + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const timeoutTimerId = window.setTimeout(() => { + if (contactsLoadVersionRef.current !== loadVersion) return + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'timeout', + title: '联系人列表加载超时', + message: `等待超过 ${timeoutMs}ms,联系人列表仍未返回。`, + reason: 'chat.getContacts 长时间未返回,可能是数据库查询繁忙或连接异常。', + occurredAt: Date.now(), + elapsedMs + }) + }, timeoutMs) + contactsLoadTimeoutTimerRef.current = timeoutTimerId + + setIsContactsListLoading(true) + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + + try { + const contactsResult = await window.electronAPI.chat.getContacts() + if (contactsLoadVersionRef.current !== loadVersion) return + + if (contactsResult.success && contactsResult.contacts) { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + const contactsWithAvatarCache = mergeAvatarCacheIntoContacts( + contactsResult.contacts, + contactsAvatarCacheRef.current + ) + setContactsList(contactsWithAvatarCache) + syncContactTypeCounts(contactsWithAvatarCache) + setContactsDataSource('network') + setContactsUpdatedAt(Date.now()) + setContactsLoadIssue(null) + setIsContactsListLoading(false) + + const upsertResult = upsertAvatarCacheFromContacts( + contactsAvatarCacheRef.current, + contactsWithAvatarCache, + { prune: true } + ) + contactsAvatarCacheRef.current = upsertResult.avatarEntries + if (upsertResult.updatedAt) { + setAvatarCacheUpdatedAt(upsertResult.updatedAt) + } + + void configService.setContactsAvatarCache(scopeKey, contactsAvatarCacheRef.current).catch((error) => { + console.error('写入导出页头像缓存失败:', error) + }) + 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 enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey) + return + } + + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人接口返回失败,未拿到联系人列表。', + reason: 'chat.getContacts 返回 success=false。', + errorDetail: contactsResult.error || '未知错误', + occurredAt: Date.now(), + elapsedMs + }) + } catch (error) { + console.error('加载导出页联系人失败:', error) + const elapsedMs = Date.now() - startedAt + setContactsLoadIssue({ + kind: 'error', + title: '联系人列表加载失败', + message: '联系人请求执行异常。', + reason: '调用 chat.getContacts 发生异常。', + errorDetail: String(error), + occurredAt: Date.now(), + elapsedMs + }) + } finally { + if (contactsLoadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + if (contactsLoadVersionRef.current === loadVersion) { + setIsContactsListLoading(false) + } + } + }, [ensureExportCacheScope, enrichContactsListInBackground, syncContactTypeCounts]) + + useEffect(() => { + if (!isExportRoute) return + let cancelled = false + void (async () => { + const scopeKey = await ensureExportCacheScope() + 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 + })) + setContactsList(cachedContacts) + syncContactTypeCounts(cachedContacts) + setContactsDataSource('cache') + setContactsUpdatedAt(cacheItem.updatedAt || null) + setIsContactsListLoading(false) + } + } catch (error) { + console.error('读取导出页联系人缓存失败:', error) + } + + if (!cancelled) { + void loadContactsList({ scopeKey }) + } + })() + return () => { + cancelled = true + } + }, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts]) + + useEffect(() => { + if (isExportRoute) return + contactsLoadVersionRef.current += 1 + setContactsAvatarEnrichProgress({ + loaded: 0, + total: 0, + running: false + }) + }, [isExportRoute]) + + useEffect(() => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + return () => { + if (contactsLoadTimeoutTimerRef.current) { + window.clearTimeout(contactsLoadTimeoutTimerRef.current) + contactsLoadTimeoutTimerRef.current = null + } + } + }, []) + + useEffect(() => { + if (!contactsLoadIssue || contactsList.length > 0) return + if (!(isContactsListLoading && contactsLoadIssue.kind === 'timeout')) return + const timer = window.setInterval(() => { + setContactsDiagnosticTick(Date.now()) + }, 500) + return () => window.clearInterval(timer) + }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -1035,28 +1422,39 @@ function ExportPage() { const openExportDialog = (payload: Omit) => { setExportDialog({ open: true, ...payload }) - if (payload.scope === 'sns') { - setOptions(prev => ({ - ...prev, - format: prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' - })) - return - } + setOptions(prev => { + const nextDateRange = prev.dateRange ?? (() => { + const now = new Date() + const start = new Date(now) + start.setHours(0, 0, 0, 0) + return { start, end: now } + })() - if (payload.scope === 'content' && payload.contentType) { - if (payload.contentType === 'text') { - setOptions(prev => ({ ...prev, exportMedia: false })) - } else { - setOptions(prev => ({ - ...prev, - exportMedia: true, - exportImages: payload.contentType === 'image', - exportVoices: payload.contentType === 'voice', - exportVideos: payload.contentType === 'video', - exportEmojis: payload.contentType === 'emoji' - })) + const next: ExportOptions = { + ...prev, + useAllTime: true, + dateRange: nextDateRange } - } + + if (payload.scope === 'sns') { + next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' + return next + } + + if (payload.scope === 'content' && payload.contentType) { + if (payload.contentType === 'text') { + next.exportMedia = false + } else { + next.exportMedia = true + next.exportImages = payload.contentType === 'image' + next.exportVoices = payload.contentType === 'voice' + next.exportVideos = payload.contentType === 'video' + next.exportEmojis = payload.contentType === 'emoji' + } + } + + return next + }) } const closeExportDialog = () => { @@ -1492,6 +1890,120 @@ function ExportPage() { return '公众号' }, [activeTab]) + const filteredContacts = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + return contactsList + .filter((contact) => { + if (!matchesContactTab(contact, activeTab)) return false + if (!keyword) return true + return ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) + }) + .sort((a, b) => (a.displayName || a.username).localeCompare(b.displayName || b.username, 'zh-Hans-CN')) + }, [contactsList, activeTab, searchKeyword]) + + const contactsUpdatedAtLabel = useMemo(() => { + if (!contactsUpdatedAt) return '' + return new Date(contactsUpdatedAt).toLocaleString() + }, [contactsUpdatedAt]) + + const avatarCacheUpdatedAtLabel = useMemo(() => { + if (!avatarCacheUpdatedAt) return '' + return new Date(avatarCacheUpdatedAt).toLocaleString() + }, [avatarCacheUpdatedAt]) + + const contactsAvatarCachedCount = useMemo(() => { + return contactsList.reduce((count, contact) => ( + contact.avatarUrl ? count + 1 : count + ), 0) + }, [contactsList]) + + useEffect(() => { + if (!contactsListRef.current) return + contactsListRef.current.scrollTop = 0 + setContactsListScrollTop(0) + }, [activeTab, searchKeyword]) + + useEffect(() => { + const node = contactsListRef.current + if (!node) return + const updateViewportHeight = () => { + setContactsListViewportHeight(Math.max(node.clientHeight, CONTACTS_LIST_VIRTUAL_ROW_HEIGHT)) + } + updateViewportHeight() + const observer = new ResizeObserver(() => updateViewportHeight()) + observer.observe(node) + return () => observer.disconnect() + }, [filteredContacts.length, isContactsListLoading]) + + useEffect(() => { + const maxScroll = Math.max(0, filteredContacts.length * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT - contactsListViewportHeight) + if (contactsListScrollTop <= maxScroll) return + setContactsListScrollTop(maxScroll) + if (contactsListRef.current) { + contactsListRef.current.scrollTop = maxScroll + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const { startIndex: contactStartIndex, endIndex: contactEndIndex } = useMemo(() => { + if (filteredContacts.length === 0) { + return { startIndex: 0, endIndex: 0 } + } + const baseStart = Math.floor(contactsListScrollTop / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const visibleCount = Math.ceil(contactsListViewportHeight / CONTACTS_LIST_VIRTUAL_ROW_HEIGHT) + const nextStart = Math.max(0, baseStart - CONTACTS_LIST_VIRTUAL_OVERSCAN) + const nextEnd = Math.min(filteredContacts.length, nextStart + visibleCount + CONTACTS_LIST_VIRTUAL_OVERSCAN * 2) + return { + startIndex: nextStart, + endIndex: nextEnd + } + }, [filteredContacts.length, contactsListViewportHeight, contactsListScrollTop]) + + const visibleContacts = useMemo(() => { + return filteredContacts.slice(contactStartIndex, contactEndIndex) + }, [filteredContacts, contactStartIndex, contactEndIndex]) + + const onContactsListScroll = useCallback((event: UIEvent) => { + setContactsListScrollTop(event.currentTarget.scrollTop) + }, []) + + const contactsIssueElapsedMs = useMemo(() => { + if (!contactsLoadIssue) return 0 + if (isContactsListLoading && contactsLoadSession) { + return Math.max(contactsLoadIssue.elapsedMs, contactsDiagnosticTick - contactsLoadSession.startedAt) + } + return contactsLoadIssue.elapsedMs + }, [contactsDiagnosticTick, isContactsListLoading, contactsLoadIssue, contactsLoadSession]) + + const contactsDiagnosticsText = useMemo(() => { + if (!contactsLoadIssue || !contactsLoadSession) return '' + return [ + `请求ID: ${contactsLoadSession.requestId}`, + `请求序号: 第 ${contactsLoadSession.attempt} 次`, + `阈值配置: ${contactsLoadSession.timeoutMs}ms`, + `当前状态: ${contactsLoadIssue.kind === 'timeout' ? '超时等待中' : '请求失败'}`, + `累计耗时: ${(contactsIssueElapsedMs / 1000).toFixed(1)}s`, + `发生时间: ${new Date(contactsLoadIssue.occurredAt).toLocaleString()}`, + '阶段: chat.getContacts', + `原因: ${contactsLoadIssue.reason}`, + `错误详情: ${contactsLoadIssue.errorDetail || '无'}` + ].join('\n') + }, [contactsIssueElapsedMs, contactsLoadIssue, contactsLoadSession]) + + const copyContactsDiagnostics = useCallback(async () => { + if (!contactsDiagnosticsText) return + try { + await navigator.clipboard.writeText(contactsDiagnosticsText) + alert('诊断信息已复制') + } catch (error) { + console.error('复制诊断信息失败:', error) + alert('复制失败,请手动复制诊断信息') + } + }, [contactsDiagnosticsText]) + const sessionContactsUpdatedAtLabel = useMemo(() => { if (!sessionContactsUpdatedAt) return '' return new Date(sessionContactsUpdatedAt).toLocaleString() @@ -1797,7 +2309,7 @@ function ExportPage() { setSearchKeyword(event.target.value)} - placeholder={`搜索${activeTabLabel}会话...`} + placeholder={`搜索${activeTabLabel}联系人...`} /> {searchKeyword && ( )} - - - - {selectedCount > 0 && ( -
- 已选中 {selectedCount} 个会话 - - -
- )}
- {sessionContactsUpdatedAt && ( + + 共 {filteredContacts.length} / {contactsList.length} 个联系人 + + {contactsUpdatedAt && ( - {sessionDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {sessionContactsUpdatedAtLabel} + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} )} - {sessions.length > 0 && ( + {contactsList.length > 0 && ( - 头像缓存 {sessionAvatarCachedCount}/{sessions.length} - {sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''} + 头像缓存 {contactsAvatarCachedCount}/{contactsList.length} + {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} )} - {(isLoading || isSessionEnriching) && sessions.length > 0 && ( + {(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && ( 后台同步中... )} + {contactsAvatarEnrichProgress.running && ( + + 头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total} + + )}
- {!showInitialSkeleton && (isLoading || isSessionEnriching) && ( + {contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && (
- {isLoading ? '导出板块数据加载中…' : '正在补充头像…'} + {isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'}
)}
- {showInitialSkeleton ? ( -
- {Array.from({ length: 8 }).map((_, rowIndex) => ( -
- - - - - - + {contactsList.length === 0 && contactsLoadIssue ? ( +
+
+
+ + {contactsLoadIssue.title}
- ))} +

{contactsLoadIssue.message}

+

{contactsLoadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showContactsDiagnostics && ( +
{contactsDiagnosticsText}
+ )} +
+
+ ) : isContactsListLoading && contactsList.length === 0 ? ( +
+ + 联系人加载中... +
+ ) : filteredContacts.length === 0 ? ( +
+ 暂无联系人
- ) : visibleSessions.length === 0 ? ( -
暂无会话
) : ( - session.username} - itemContent={(_, session) => renderRowCells(session)} - overscan={420} - /> +
+
+ {visibleContacts.map((contact, idx) => { + const absoluteIndex = contactStartIndex + idx + const top = absoluteIndex * CONTACTS_LIST_VIRTUAL_ROW_HEIGHT + return ( +
+
+
+ {contact.avatarUrl ? ( + + ) : ( + {getAvatarLetter(contact.displayName)} + )} +
+
+
{contact.displayName}
+
{contact.username}
+
+
+ {getContactTypeName(contact.type)} +
+
+
+ ) + })} +
+
)}
@@ -1994,18 +2557,23 @@ function ExportPage() {

发送者名称显示

-
- {displayNameOptions.map(option => ( - - ))} +
+ {displayNameOptions.map(option => { + const isActive = options.displayNamePreference === option.value + return ( + + ) + })}