diff --git a/src/pages/ContactsPage.scss b/src/pages/ContactsPage.scss index 541f428..bd6fc98 100644 --- a/src/pages/ContactsPage.scss +++ b/src/pages/ContactsPage.scss @@ -189,6 +189,16 @@ font-size: 13px; color: var(--text-secondary); + .contacts-cache-meta { + margin-left: 10px; + color: var(--text-tertiary); + font-size: 12px; + + &.syncing { + color: var(--primary); + } + } + .avatar-enrich-progress { margin-left: 10px; color: var(--text-tertiary); @@ -230,6 +240,98 @@ } } + .load-issue-state { + flex: 1; + padding: 14px 14px 18px; + overflow-y: auto; + } + + .issue-card { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--card-bg)); + border-radius: 12px; + padding: 14px; + color: var(--text-primary); + + .issue-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + margin-bottom: 8px; + } + + .issue-message { + margin: 0 0 8px; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-reason { + margin: 0; + font-size: 13px; + color: var(--text-secondary); + line-height: 1.5; + } + + .issue-hints { + margin: 10px 0 0; + padding-left: 18px; + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.6; + } + + .issue-actions { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + .issue-btn { + border: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 8px; + padding: 7px 10px; + font-size: 12px; + color: var(--text-secondary); + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + background: var(--bg-hover); + } + + &.primary { + background: color-mix(in srgb, var(--primary) 14%, var(--bg-secondary)); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + color: var(--primary); + } + } + + .issue-diagnostics { + margin-top: 12px; + border-radius: 8px; + background: var(--bg-primary); + border: 1px dashed var(--border-color); + padding: 10px; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + } + } + .contacts-list { flex: 1; overflow-y: auto; diff --git a/src/pages/ContactsPage.tsx b/src/pages/ContactsPage.tsx index 77271c0..6a87372 100644 --- a/src/pages/ContactsPage.tsx +++ b/src/pages/ContactsPage.tsx @@ -1,8 +1,9 @@ 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 { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react' import { useChatStore } from '../stores/chatStore' import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import * as configService from '../services/config' import './ContactsPage.scss' interface ContactInfo { @@ -23,6 +24,26 @@ 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 + +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([]) @@ -61,6 +82,53 @@ function ContactsPage() { const [listViewportHeight, setListViewportHeight] = useState(480) const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts) const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts) + const loadAttemptRef = useRef(0) + const loadTimeoutTimerRef = useRef(null) + const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const [loadSession, setLoadSession] = useState(null) + const [loadIssue, setLoadIssue] = useState(null) + const [showDiagnostics, setShowDiagnostics] = useState(false) + const [diagnosticTick, setDiagnosticTick] = useState(Date.now()) + const [contactsDataSource, setContactsDataSource] = useState(null) + const [contactsUpdatedAt, setContactsUpdatedAt] = useState(null) + const contactsLoadTimeoutMsRef = useRef(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) + const contactsCacheScopeRef = useRef('default') + + 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 applyEnrichedContacts = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return @@ -139,9 +207,40 @@ function ContactsPage() { }, [applyEnrichedContacts]) // 加载通讯录 - const loadContacts = useCallback(async () => { + 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, @@ -153,6 +252,10 @@ function ContactsPage() { if (loadVersionRef.current !== loadVersion) return if (contactsResult.success && contactsResult.contacts) { + if (loadTimeoutTimerRef.current === timeoutTimerId) { + window.clearTimeout(loadTimeoutTimerRef.current) + loadTimeoutTimerRef.current = null + } setContacts(contactsResult.contacts) syncContactTypeCounts(contactsResult.contacts) setSelectedUsernames(new Set()) @@ -160,29 +263,108 @@ function ContactsPage() { if (!prev) return prev return contactsResult.contacts!.find(contact => contact.username === prev.username) || null }) + const now = Date.now() + setContactsDataSource('network') + setContactsUpdatedAt(now) + setLoadIssue(null) setIsLoading(false) + void configService.setContactsListCache( + scopeKey, + contactsResult.contacts.map(contact => ({ + username: contact.username, + displayName: contact.displayName, + remark: contact.remark, + nickname: contact.nickname, + type: contact.type + })) + ).catch((error) => { + console.error('写入通讯录缓存失败:', error) + }) void enrichContactsInBackground(contactsResult.contacts, loadVersion) 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) } } - }, [enrichContactsInBackground, syncContactTypeCounts]) + }, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts]) useEffect(() => { - loadContacts() - }, [loadContacts]) + let cancelled = false + void (async () => { + const scopeKey = await ensureContactsCacheScope() + if (cancelled) return + try { + const cacheItem = await configService.getContactsListCache(scopeKey) + if (!cancelled && cacheItem && Array.isArray(cacheItem.contacts) && cacheItem.contacts.length > 0) { + const cachedContacts: ContactInfo[] = cacheItem.contacts.map(contact => ({ + ...contact, + avatarUrl: undefined + })) + 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()) @@ -282,6 +464,45 @@ function ContactsPage() { 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 toggleContactSelected = (username: string, checked: boolean) => { setSelectedUsernames(prev => { const next = new Set(prev) @@ -410,7 +631,7 @@ function ContactsPage() { > - @@ -460,6 +681,14 @@ function ContactsPage() {
共 {filteredContacts.length} / {contacts.length} 个联系人 + {contactsUpdatedAt && ( + + {contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel} + + )} + {isLoading && contacts.length > 0 && ( + 后台同步中... + )} {avatarEnrichProgress.running && ( 头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total} @@ -482,7 +711,39 @@ function ContactsPage() {
)} - {isLoading && contacts.length === 0 ? ( + {contacts.length === 0 && loadIssue ? ( +
+
+
+ + {loadIssue.title} +
+

{loadIssue.message}

+

{loadIssue.reason}

+
    +
  • 可能原因1:数据库当前仍在执行高开销查询(例如导出页后台统计)。
  • +
  • 可能原因2:contact.db 数据量较大,首次查询时间过长。
  • +
  • 可能原因3:数据库连接状态异常或 IPC 调用卡住。
  • +
+
+ + + +
+ {showDiagnostics && ( +
{diagnosticsText}
+ )} +
+
+ ) : isLoading && contacts.length === 0 ? (
联系人加载中... diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 02774f1..7f4861d 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -222,6 +222,10 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co return 'private' } +const isContentScopeSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + const getAvatarLetter = (name: string): string => { if (!name) return '?' return [...name][0] || '?' @@ -1327,11 +1331,11 @@ function ExportPage() { const openContentExport = (contentType: ContentType) => { const ids = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.username) const names = sessions - .filter(session => session.kind === 'private' || session.kind === 'group') + .filter(isContentScopeSession) .map(session => session.displayName || session.username) openExportDialog({ @@ -1375,8 +1379,8 @@ function ExportPage() { }, [tasks]) const contentCards = useMemo(() => { - const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group') - const totalSessions = scopeSessions.length + const scopeSessions = sessions.filter(isContentScopeSession) + const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) const sessionCards = [ @@ -1414,7 +1418,7 @@ function ExportPage() { } return [...sessionCards, snsCard] - }, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount]) + }, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' @@ -1606,7 +1610,7 @@ function ExportPage() { ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') : formatOptions const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady) const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length diff --git a/src/services/config.ts b/src/services/config.ts index 53969ef..3ea4652 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -38,6 +38,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', + CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', + CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -462,6 +464,19 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ContactsListCacheContact { + username: string + displayName: string + remark?: string + nickname?: string + type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +export interface ContactsListCacheItem { + updatedAt: number + contacts: ContactsListCacheContact[] +} + export async function getExportSessionMessageCountCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) @@ -549,6 +564,92 @@ export async function setExportSnsStatsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) } +// 获取通讯录加载超时阈值(毫秒) +export async function getContactsLoadTimeoutMs(): Promise { + const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) + if (typeof value === 'number' && Number.isFinite(value) && value >= 1000 && value <= 60000) { + return Math.floor(value) + } + return 3000 +} + +// 设置通讯录加载超时阈值(毫秒) +export async function setContactsLoadTimeoutMs(timeoutMs: number): Promise { + const normalized = Number.isFinite(timeoutMs) + ? Math.min(60000, Math.max(1000, Math.floor(timeoutMs))) + : 3000 + await config.set(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS, normalized) +} + +export async function getContactsListCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawContacts = (rawItem as Record).contacts + if (!Array.isArray(rawContacts)) return null + + const contacts: ContactsListCacheContact[] = [] + for (const raw of rawContacts) { + if (!raw || typeof raw !== 'object') continue + const item = raw as Record + const username = typeof item.username === 'string' ? item.username.trim() : '' + if (!username) continue + const displayName = typeof item.displayName === 'string' ? item.displayName : username + const type = typeof item.type === 'string' ? item.type : 'other' + contacts.push({ + username, + displayName, + remark: typeof item.remark === 'string' ? item.remark : undefined, + nickname: typeof item.nickname === 'string' ? item.nickname : undefined, + type: (type === 'friend' || type === 'group' || type === 'official' || type === 'former_friend' || type === 'other') + ? type + : 'other' + }) + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + contacts + } +} + +export async function setContactsListCache(scopeKey: string, contacts: ContactsListCacheContact[]): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: ContactsListCacheContact[] = [] + for (const contact of contacts || []) { + const username = String(contact?.username || '').trim() + if (!username) continue + const displayName = String(contact?.displayName || username) + const type = contact?.type || 'other' + if (type !== 'friend' && type !== 'group' && type !== 'official' && type !== 'former_friend' && type !== 'other') { + continue + } + normalized.push({ + username, + displayName, + remark: contact?.remark ? String(contact.remark) : undefined, + nickname: contact?.nickname ? String(contact.nickname) : undefined, + type + }) + } + + map[scopeKey] = { + updatedAt: Date.now(), + contacts: normalized + } + await config.set(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise {