mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
fix(contacts): persist list cache and add load timeout diagnostics
This commit is contained in:
@@ -189,6 +189,16 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
.contacts-cache-meta {
|
||||||
|
margin-left: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
&.syncing {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-enrich-progress {
|
.avatar-enrich-progress {
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: var(--text-tertiary);
|
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 {
|
.contacts-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
|
import { useState, useEffect, useCallback, useMemo, useRef, type UIEvent } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
|
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX, AlertTriangle, ClipboardList } from 'lucide-react'
|
||||||
import { useChatStore } from '../stores/chatStore'
|
import { useChatStore } from '../stores/chatStore'
|
||||||
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { toContactTypeCardCounts, useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
|
import * as configService from '../services/config'
|
||||||
import './ContactsPage.scss'
|
import './ContactsPage.scss'
|
||||||
|
|
||||||
interface ContactInfo {
|
interface ContactInfo {
|
||||||
@@ -23,6 +24,26 @@ const AVATAR_ENRICH_BATCH_SIZE = 80
|
|||||||
const SEARCH_DEBOUNCE_MS = 120
|
const SEARCH_DEBOUNCE_MS = 120
|
||||||
const VIRTUAL_ROW_HEIGHT = 76
|
const VIRTUAL_ROW_HEIGHT = 76
|
||||||
const VIRTUAL_OVERSCAN = 10
|
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() {
|
function ContactsPage() {
|
||||||
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
const [contacts, setContacts] = useState<ContactInfo[]>([])
|
||||||
@@ -61,6 +82,53 @@ function ContactsPage() {
|
|||||||
const [listViewportHeight, setListViewportHeight] = useState(480)
|
const [listViewportHeight, setListViewportHeight] = useState(480)
|
||||||
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
const sharedTabCounts = useContactTypeCountsStore(state => state.tabCounts)
|
||||||
const syncContactTypeCounts = useContactTypeCountsStore(state => state.syncFromContacts)
|
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 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<string, ContactEnrichInfo>) => {
|
const applyEnrichedContacts = useCallback((enrichedMap: Record<string, ContactEnrichInfo>) => {
|
||||||
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
if (!enrichedMap || Object.keys(enrichedMap).length === 0) return
|
||||||
@@ -139,9 +207,40 @@ function ContactsPage() {
|
|||||||
}, [applyEnrichedContacts])
|
}, [applyEnrichedContacts])
|
||||||
|
|
||||||
// 加载通讯录
|
// 加载通讯录
|
||||||
const loadContacts = useCallback(async () => {
|
const loadContacts = useCallback(async (options?: { scopeKey?: string }) => {
|
||||||
|
const scopeKey = options?.scopeKey || await ensureContactsCacheScope()
|
||||||
const loadVersion = loadVersionRef.current + 1
|
const loadVersion = loadVersionRef.current + 1
|
||||||
loadVersionRef.current = loadVersion
|
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)
|
setIsLoading(true)
|
||||||
setAvatarEnrichProgress({
|
setAvatarEnrichProgress({
|
||||||
loaded: 0,
|
loaded: 0,
|
||||||
@@ -153,6 +252,10 @@ function ContactsPage() {
|
|||||||
|
|
||||||
if (loadVersionRef.current !== loadVersion) return
|
if (loadVersionRef.current !== loadVersion) return
|
||||||
if (contactsResult.success && contactsResult.contacts) {
|
if (contactsResult.success && contactsResult.contacts) {
|
||||||
|
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||||||
|
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||||
|
loadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
setContacts(contactsResult.contacts)
|
setContacts(contactsResult.contacts)
|
||||||
syncContactTypeCounts(contactsResult.contacts)
|
syncContactTypeCounts(contactsResult.contacts)
|
||||||
setSelectedUsernames(new Set())
|
setSelectedUsernames(new Set())
|
||||||
@@ -160,29 +263,108 @@ function ContactsPage() {
|
|||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
return contactsResult.contacts!.find(contact => contact.username === prev.username) || null
|
return contactsResult.contacts!.find(contact => contact.username === prev.username) || null
|
||||||
})
|
})
|
||||||
|
const now = Date.now()
|
||||||
|
setContactsDataSource('network')
|
||||||
|
setContactsUpdatedAt(now)
|
||||||
|
setLoadIssue(null)
|
||||||
setIsLoading(false)
|
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)
|
void enrichContactsInBackground(contactsResult.contacts, loadVersion)
|
||||||
return
|
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) {
|
} catch (e) {
|
||||||
console.error('加载通讯录失败:', 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 {
|
} finally {
|
||||||
|
if (loadTimeoutTimerRef.current === timeoutTimerId) {
|
||||||
|
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||||
|
loadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
if (loadVersionRef.current === loadVersion) {
|
if (loadVersionRef.current === loadVersion) {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [enrichContactsInBackground, syncContactTypeCounts])
|
}, [ensureContactsCacheScope, enrichContactsInBackground, syncContactTypeCounts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadContacts()
|
let cancelled = false
|
||||||
}, [loadContacts])
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
if (loadTimeoutTimerRef.current) {
|
||||||
|
window.clearTimeout(loadTimeoutTimerRef.current)
|
||||||
|
loadTimeoutTimerRef.current = null
|
||||||
|
}
|
||||||
loadVersionRef.current += 1
|
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(() => {
|
useEffect(() => {
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
|
setDebouncedSearchKeyword(searchKeyword.trim().toLowerCase())
|
||||||
@@ -282,6 +464,45 @@ function ContactsPage() {
|
|||||||
setScrollTop(event.currentTarget.scrollTop)
|
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) => {
|
const toggleContactSelected = (username: string, checked: boolean) => {
|
||||||
setSelectedUsernames(prev => {
|
setSelectedUsernames(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -410,7 +631,7 @@ function ContactsPage() {
|
|||||||
>
|
>
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
|
<button className="icon-btn" onClick={() => void loadContacts()} disabled={isLoading}>
|
||||||
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,6 +681,14 @@ function ContactsPage() {
|
|||||||
|
|
||||||
<div className="contacts-count">
|
<div className="contacts-count">
|
||||||
共 {filteredContacts.length} / {contacts.length} 个联系人
|
共 {filteredContacts.length} / {contacts.length} 个联系人
|
||||||
|
{contactsUpdatedAt && (
|
||||||
|
<span className="contacts-cache-meta">
|
||||||
|
{contactsDataSource === 'cache' ? '缓存' : '最新'} · 更新于 {contactsUpdatedAtLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isLoading && contacts.length > 0 && (
|
||||||
|
<span className="contacts-cache-meta syncing">后台同步中...</span>
|
||||||
|
)}
|
||||||
{avatarEnrichProgress.running && (
|
{avatarEnrichProgress.running && (
|
||||||
<span className="avatar-enrich-progress">
|
<span className="avatar-enrich-progress">
|
||||||
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
头像补全中 {avatarEnrichProgress.loaded}/{avatarEnrichProgress.total}
|
||||||
@@ -482,7 +711,39 @@ function ContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && contacts.length === 0 ? (
|
{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">
|
<div className="loading-state">
|
||||||
<Loader2 size={32} className="spin" />
|
<Loader2 size={32} className="spin" />
|
||||||
<span>联系人加载中...</span>
|
<span>联系人加载中...</span>
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ const toKindByContactType = (session: AppChatSession, contact?: ContactInfo): Co
|
|||||||
return 'private'
|
return 'private'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isContentScopeSession = (session: SessionRow): boolean => (
|
||||||
|
session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend'
|
||||||
|
)
|
||||||
|
|
||||||
const getAvatarLetter = (name: string): string => {
|
const getAvatarLetter = (name: string): string => {
|
||||||
if (!name) return '?'
|
if (!name) return '?'
|
||||||
return [...name][0] || '?'
|
return [...name][0] || '?'
|
||||||
@@ -1327,11 +1331,11 @@ function ExportPage() {
|
|||||||
|
|
||||||
const openContentExport = (contentType: ContentType) => {
|
const openContentExport = (contentType: ContentType) => {
|
||||||
const ids = sessions
|
const ids = sessions
|
||||||
.filter(session => session.kind === 'private' || session.kind === 'group')
|
.filter(isContentScopeSession)
|
||||||
.map(session => session.username)
|
.map(session => session.username)
|
||||||
|
|
||||||
const names = sessions
|
const names = sessions
|
||||||
.filter(session => session.kind === 'private' || session.kind === 'group')
|
.filter(isContentScopeSession)
|
||||||
.map(session => session.displayName || session.username)
|
.map(session => session.displayName || session.username)
|
||||||
|
|
||||||
openExportDialog({
|
openExportDialog({
|
||||||
@@ -1375,8 +1379,8 @@ function ExportPage() {
|
|||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
const contentCards = useMemo(() => {
|
const contentCards = useMemo(() => {
|
||||||
const scopeSessions = sessions.filter(session => session.kind === 'private' || session.kind === 'group')
|
const scopeSessions = sessions.filter(isContentScopeSession)
|
||||||
const totalSessions = scopeSessions.length
|
const totalSessions = tabCounts.private + tabCounts.group + tabCounts.former_friend
|
||||||
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
|
const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts)
|
||||||
|
|
||||||
const sessionCards = [
|
const sessionCards = [
|
||||||
@@ -1414,7 +1418,7 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [...sessionCards, snsCard]
|
return [...sessionCards, snsCard]
|
||||||
}, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount])
|
}, [sessions, tabCounts, lastExportByContent, snsStats, lastSnsExportPostCount])
|
||||||
|
|
||||||
const activeTabLabel = useMemo(() => {
|
const activeTabLabel = useMemo(() => {
|
||||||
if (activeTab === 'private') return '私聊'
|
if (activeTab === 'private') return '私聊'
|
||||||
@@ -1606,7 +1610,7 @@ function ExportPage() {
|
|||||||
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
|
? formatOptions.filter(option => option.value === 'html' || option.value === 'json')
|
||||||
: formatOptions
|
: formatOptions
|
||||||
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady
|
||||||
const isSessionCardStatsLoading = isLoading || isBaseConfigLoading
|
const isSessionCardStatsLoading = isBaseConfigLoading || (isSharedTabCountsLoading && !isSharedTabCountsReady)
|
||||||
const isSnsCardStatsLoading = !hasSeededSnsStats
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||||
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||||
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
|
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
|
||||||
|
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
|
||||||
|
|
||||||
// 安全
|
// 安全
|
||||||
AUTH_ENABLED: 'authEnabled',
|
AUTH_ENABLED: 'authEnabled',
|
||||||
@@ -462,6 +464,19 @@ export interface ExportSnsStatsCacheItem {
|
|||||||
totalFriends: number
|
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<ExportSessionMessageCountCacheItem | null> {
|
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
|
||||||
if (!scopeKey) return null
|
if (!scopeKey) return null
|
||||||
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
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)
|
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取通讯录加载超时阈值(毫秒)
|
||||||
|
export async function getContactsLoadTimeoutMs(): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
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<ContactsListCacheItem | null> {
|
||||||
|
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<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawContacts = (rawItem as Record<string, unknown>).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<string, unknown>
|
||||||
|
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<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.CONTACTS_LIST_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
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<boolean> {
|
export async function getAuthEnabled(): Promise<boolean> {
|
||||||
|
|||||||
Reference in New Issue
Block a user