feat(export): reuse contacts cache for session names and avatars

This commit is contained in:
tisonhuang
2026-03-02 11:21:53 +08:00
parent faeda030e9
commit 0a1f55f6a6
2 changed files with 224 additions and 37 deletions

View File

@@ -472,6 +472,22 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.table-cache-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 12px;
.meta-item {
color: var(--text-tertiary);
}
.meta-item.syncing {
color: var(--primary);
}
}
.table-tabs { .table-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@@ -219,6 +219,8 @@ const getAvatarLetter = (name: string): string => {
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const CONTACT_ENRICH_TIMEOUT_MS = 7000 const CONTACT_ENRICH_TIMEOUT_MS = 7000
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
type SessionDataSource = 'cache' | 'network' | null
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => { const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
let timer: ReturnType<typeof setTimeout> | null = null let timer: ReturnType<typeof setTimeout> | null = null
@@ -236,6 +238,39 @@ const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<
} }
} }
const toContactMapFromCaches = (
contacts: configService.ContactsListCacheContact[],
avatarEntries: Record<string, configService.ContactsAvatarCacheEntry>
): Record<string, ContactInfo> => {
const map: Record<string, ContactInfo> = {}
for (const contact of contacts || []) {
if (!contact?.username) continue
map[contact.username] = {
...contact,
avatarUrl: avatarEntries[contact.username]?.avatarUrl
}
}
return map
}
const toSessionRowsWithContacts = (
sessions: AppChatSession[],
contactMap: Record<string, ContactInfo>
): SessionRow[] => {
return sessions
.map((session) => {
const contact = contactMap[session.username]
return {
...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.username,
displayName: contact?.displayName || session.displayName || session.username,
avatarUrl: contact?.avatarUrl || session.avatarUrl
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
}
const WriteLayoutSelector = memo(function WriteLayoutSelector({ const WriteLayoutSelector = memo(function WriteLayoutSelector({
writeLayout, writeLayout,
onChange onChange
@@ -300,6 +335,9 @@ function ExportPage() {
const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true)
const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false) const [isTaskCenterExpanded, setIsTaskCenterExpanded] = useState(false)
const [sessions, setSessions] = useState<SessionRow[]>([]) const [sessions, setSessions] = useState<SessionRow[]>([])
const [sessionDataSource, setSessionDataSource] = useState<SessionDataSource>(null)
const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState<number | null>(null)
const [sessionAvatarUpdatedAt, setSessionAvatarUpdatedAt] = useState<number | null>(null)
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [activeTab, setActiveTab] = useState<ConversationTab>('private') const [activeTab, setActiveTab] = useState<ConversationTab>('private')
const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set()) const [selectedSessions, setSelectedSessions] = useState<Set<string>>(new Set())
@@ -360,6 +398,22 @@ function ExportPage() {
const exportCacheScopeRef = useRef('default') const exportCacheScopeRef = useRef('default')
const exportCacheScopeReadyRef = useRef(false) const exportCacheScopeReadyRef = useRef(false)
const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) {
return exportCacheScopeRef.current
}
const [myWxid, dbPath] = await Promise.all([
configService.getMyWxid(),
configService.getDbPath()
])
const scopeKey = dbPath || myWxid
? `${dbPath || ''}::${myWxid || ''}`
: 'default'
exportCacheScopeRef.current = scopeKey
exportCacheScopeReadyRef.current = true
return scopeKey
}, [])
useEffect(() => { useEffect(() => {
tasksRef.current = tasks tasksRef.current = tasks
}, [tasks]) }, [tasks])
@@ -389,7 +443,7 @@ function ExportPage() {
const loadBaseConfig = useCallback(async () => { const loadBaseConfig = useCallback(async () => {
setIsBaseConfigLoading(true) setIsBaseConfigLoading(true)
try { try {
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, exportCacheScope] = await Promise.all([
configService.getExportPath(), configService.getExportPath(),
configService.getExportDefaultFormat(), configService.getExportDefaultFormat(),
configService.getExportDefaultMedia(), configService.getExportDefaultMedia(),
@@ -401,12 +455,8 @@ function ExportPage() {
configService.getExportLastSessionRunMap(), configService.getExportLastSessionRunMap(),
configService.getExportLastContentRunMap(), configService.getExportLastContentRunMap(),
configService.getExportLastSnsPostCount(), configService.getExportLastSnsPostCount(),
configService.getMyWxid(), ensureExportCacheScope()
configService.getDbPath()
]) ])
const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default'
exportCacheScopeRef.current = exportCacheScope
exportCacheScopeReadyRef.current = true
const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope) const cachedSnsStats = await configService.getExportSnsStatsCache(exportCacheScope)
@@ -446,7 +496,7 @@ function ExportPage() {
} finally { } finally {
setIsBaseConfigLoading(false) setIsBaseConfigLoading(false)
} }
}, []) }, [ensureExportCacheScope])
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
if (!options?.silent) { if (!options?.silent) {
@@ -506,6 +556,24 @@ function ExportPage() {
const isStale = () => sessionLoadTokenRef.current !== loadToken const isStale = () => sessionLoadTokenRef.current !== loadToken
try { try {
const scopeKey = await ensureExportCacheScope()
if (isStale()) return
const [cachedContactsItem, cachedAvatarItem] = await Promise.all([
configService.getContactsListCache(scopeKey),
configService.getContactsAvatarCache(scopeKey)
])
if (isStale()) return
const cachedContacts = cachedContactsItem?.contacts || []
const cachedAvatarEntries = cachedAvatarItem?.avatars || {}
const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries)
if (cachedContacts.length > 0) {
syncContactTypeCounts(Object.values(cachedContactMap))
}
setSessionContactsUpdatedAt(cachedContactsItem?.updatedAt || null)
setSessionAvatarUpdatedAt(cachedAvatarItem?.updatedAt || null)
const connectResult = await window.electronAPI.chat.connect() const connectResult = await window.electronAPI.chat.connect()
if (!connectResult.success) { if (!connectResult.success) {
console.error('连接失败:', connectResult.error) console.error('连接失败:', connectResult.error)
@@ -517,42 +585,54 @@ function ExportPage() {
if (isStale()) return if (isStale()) return
if (sessionsResult.success && sessionsResult.sessions) { if (sessionsResult.success && sessionsResult.sessions) {
const baseSessions = sessionsResult.sessions const rawSessions = sessionsResult.sessions
.map((session) => { const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap)
return {
...session,
kind: toKindByContactType(session),
wechatId: session.username,
displayName: session.displayName || session.username,
avatarUrl: session.avatarUrl
} as SessionRow
})
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
if (isStale()) return if (isStale()) return
setSessions(baseSessions) setSessions(baseSessions)
setSessionDataSource(cachedContacts.length > 0 ? 'cache' : 'network')
if (cachedContacts.length === 0) {
setSessionContactsUpdatedAt(Date.now())
}
setIsLoading(false) setIsLoading(false)
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
setIsSessionEnriching(true) setIsSessionEnriching(true)
void (async () => { void (async () => {
try { try {
if (isStale()) return let contactMap = { ...cachedContactMap }
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) let avatarEntries = { ...cachedAvatarEntries }
if (isStale()) return let hasFreshNetworkData = false
const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : [] if (isStale()) return
if (contacts.length > 0) { if (cachedContacts.length === 0) {
syncContactTypeCounts(contacts) const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return
const contacts: ContactInfo[] = contactsResult?.success && contactsResult.contacts ? contactsResult.contacts : []
if (contacts.length > 0) {
hasFreshNetworkData = true
syncContactTypeCounts(contacts)
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
contactMap = nextContactMap
setSessionContactsUpdatedAt(Date.now())
}
} }
const nextContactMap = contacts.reduce<Record<string, ContactInfo>>((map, contact) => {
map[contact.username] = contact
return map
}, {})
const now = Date.now()
const needsEnrichment = baseSessions const needsEnrichment = baseSessions
.filter(session => !session.avatarUrl || !session.displayName || session.displayName === session.username) .filter((session) => {
.map(session => session.username) const contact = contactMap[session.username]
const avatarEntry = avatarEntries[session.username]
const displayName = contact?.displayName || session.displayName || session.username
const avatarUrl = contact?.avatarUrl || session.avatarUrl || avatarEntry?.avatarUrl
const shouldRecheckAvatar = !avatarEntry || (now - (avatarEntry.checkedAt || 0) >= EXPORT_AVATAR_RECHECK_INTERVAL_MS)
return !avatarUrl || displayName === session.username || shouldRecheckAvatar
})
.map((session) => session.username)
let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {} let extraContactMap: Record<string, { displayName?: string; avatarUrl?: string }> = {}
if (needsEnrichment.length > 0) { if (needsEnrichment.length > 0) {
@@ -563,27 +643,87 @@ function ExportPage() {
) )
if (enrichResult?.success && enrichResult.contacts) { if (enrichResult?.success && enrichResult.contacts) {
extraContactMap = enrichResult.contacts extraContactMap = enrichResult.contacts
hasFreshNetworkData = true
}
}
const persistAt = Date.now()
for (const contact of Object.values(contactMap)) {
const avatarUrl = String(contact.avatarUrl || '').trim()
if (!avatarUrl) continue
const prev = avatarEntries[contact.username]
avatarEntries[contact.username] = {
avatarUrl,
updatedAt: prev?.avatarUrl === avatarUrl ? prev.updatedAt : persistAt,
checkedAt: prev?.checkedAt || persistAt
}
}
for (const username of needsEnrichment) {
const extra = extraContactMap[username]
const prev = avatarEntries[username]
if (extra?.avatarUrl) {
avatarEntries[username] = {
avatarUrl: extra.avatarUrl,
updatedAt: !prev || prev.avatarUrl !== extra.avatarUrl ? persistAt : prev.updatedAt,
checkedAt: persistAt
}
} else if (prev) {
avatarEntries[username] = {
...prev,
checkedAt: persistAt
}
}
if (!extra) continue
const current = contactMap[username]
if (!current) continue
const nextDisplayName = extra.displayName || current.displayName
const nextAvatarUrl = extra.avatarUrl || current.avatarUrl
if (nextDisplayName !== current.displayName || nextAvatarUrl !== current.avatarUrl) {
contactMap[username] = {
...current,
displayName: nextDisplayName,
avatarUrl: nextAvatarUrl
}
} }
} }
if (isStale()) return if (isStale()) return
const nextSessions = baseSessions const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap)
.map((session) => { .map((session) => {
const contact = nextContactMap[session.username]
const extra = extraContactMap[session.username] const extra = extraContactMap[session.username]
const displayName = extra?.displayName || contact?.displayName || session.displayName || session.username const displayName = extra?.displayName || session.displayName || session.username
const avatarUrl = extra?.avatarUrl || session.avatarUrl || contact?.avatarUrl const avatarUrl = extra?.avatarUrl || session.avatarUrl
if (displayName === session.displayName && avatarUrl === session.avatarUrl) {
return session
}
return { return {
...session, ...session,
kind: toKindByContactType(session, contact),
wechatId: contact?.username || session.wechatId || session.username,
displayName, displayName,
avatarUrl avatarUrl
} }
}) })
.sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0)) .sort((a, b) => (b.sortTimestamp || b.lastTimestamp || 0) - (a.sortTimestamp || a.lastTimestamp || 0))
const contactsCachePayload = Object.values(contactMap).map((contact) => ({
username: contact.username,
displayName: contact.displayName || contact.username,
remark: contact.remark,
nickname: contact.nickname,
type: contact.type
}))
setSessions(nextSessions) setSessions(nextSessions)
if (contactsCachePayload.length > 0) {
await configService.setContactsListCache(scopeKey, contactsCachePayload)
setSessionContactsUpdatedAt(persistAt)
}
await configService.setContactsAvatarCache(scopeKey, avatarEntries)
setSessionAvatarUpdatedAt(persistAt)
if (hasFreshNetworkData) {
setSessionDataSource('network')
}
} catch (enrichError) { } catch (enrichError) {
console.error('导出页补充会话联系人信息失败:', enrichError) console.error('导出页补充会话联系人信息失败:', enrichError)
} finally { } finally {
@@ -599,7 +739,7 @@ function ExportPage() {
} finally { } finally {
if (!isStale()) setIsLoading(false) if (!isStale()) setIsLoading(false)
} }
}, [syncContactTypeCounts]) }, [ensureExportCacheScope, syncContactTypeCounts])
useEffect(() => { useEffect(() => {
if (!isExportRoute) return if (!isExportRoute) return
@@ -1151,6 +1291,20 @@ function ExportPage() {
return '公众号' return '公众号'
}, [activeTab]) }, [activeTab])
const sessionContactsUpdatedAtLabel = useMemo(() => {
if (!sessionContactsUpdatedAt) return ''
return new Date(sessionContactsUpdatedAt).toLocaleString()
}, [sessionContactsUpdatedAt])
const sessionAvatarUpdatedAtLabel = useMemo(() => {
if (!sessionAvatarUpdatedAt) return ''
return new Date(sessionAvatarUpdatedAt).toLocaleString()
}, [sessionAvatarUpdatedAt])
const sessionAvatarCachedCount = useMemo(() => {
return sessions.reduce((count, session) => (session.avatarUrl ? count + 1 : count), 0)
}, [sessions])
const renderSessionName = (session: SessionRow) => { const renderSessionName = (session: SessionRow) => {
return ( return (
<div className="session-cell"> <div className="session-cell">
@@ -1452,6 +1606,23 @@ function ExportPage() {
</div> </div>
</div> </div>
<div className="table-cache-meta">
{sessionContactsUpdatedAt && (
<span className="meta-item">
{sessionDataSource === 'cache' ? '缓存' : '最新'} · {sessionContactsUpdatedAtLabel}
</span>
)}
{sessions.length > 0 && (
<span className="meta-item">
{sessionAvatarCachedCount}/{sessions.length}
{sessionAvatarUpdatedAtLabel ? ` · 更新于 ${sessionAvatarUpdatedAtLabel}` : ''}
</span>
)}
{(isLoading || isSessionEnriching) && sessions.length > 0 && (
<span className="meta-item syncing">...</span>
)}
</div>
{!showInitialSkeleton && (isLoading || isSessionEnriching) && ( {!showInitialSkeleton && (isLoading || isSessionEnriching) && (
<div className="table-stage-hint"> <div className="table-stage-hint">
<Loader2 size={14} className="spin" /> <Loader2 size={14} className="spin" />