feat(sns): cache page data and show count loading state

This commit is contained in:
tisonhuang
2026-03-02 14:09:07 +08:00
parent b8ede4cfd0
commit 21a97b8871
4 changed files with 258 additions and 24 deletions

View File

@@ -8,6 +8,7 @@ interface Contact {
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
postCount?: number postCount?: number
postCountStatus?: 'loading' | 'ready' | 'error'
} }
interface SnsFilterPanelProps { interface SnsFilterPanelProps {
@@ -58,6 +59,16 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
setJumpTargetDate(undefined) setJumpTargetDate(undefined)
} }
const getPostCountDisplay = (contact: Contact) => {
if (contact.postCountStatus === 'error') {
return { text: '统计失败', className: 'is-error' }
}
if (contact.postCountStatus !== 'ready') {
return { text: '统计中', className: 'is-loading' }
}
return { text: `${Math.max(0, Number(contact.postCount || 0))}`, className: '' }
}
return ( return (
<aside className="sns-filter-panel"> <aside className="sns-filter-panel">
<div className="filter-header"> <div className="filter-header">
@@ -144,7 +155,9 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div> </div>
<div className="contact-list-scroll"> <div className="contact-list-scroll">
{filteredContacts.map(contact => ( {filteredContacts.map(contact => {
const countDisplay = getPostCountDisplay(contact)
return (
<div <div
key={contact.username} key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`} className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
@@ -153,10 +166,11 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" /> <Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta"> <div className="contact-meta">
<span className="contact-name">{contact.displayName}</span> <span className="contact-name">{contact.displayName}</span>
<span className="contact-post-count">{Math.max(0, Number(contact.postCount || 0))} </span> <span className={`contact-post-count ${countDisplay.className}`}>{countDisplay.text}</span>
</div> </div>
</div> </div>
))} )
})}
{filteredContacts.length === 0 && ( {filteredContacts.length === 0 && (
<div className="empty-state"></div> <div className="empty-state"></div>
)} )}

View File

@@ -1113,6 +1113,14 @@
font-size: 12px; font-size: 12px;
color: var(--text-tertiary); color: var(--text-tertiary);
line-height: 1.2; line-height: 1.2;
&.is-loading {
font-style: italic;
}
&.is-error {
color: var(--color-error, #f44336);
}
} }
} }
} }

View File

@@ -5,13 +5,19 @@ import './SnsPage.scss'
import { SnsPost } from '../types/sns' import { SnsPost } from '../types/sns'
import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsPostItem } from '../components/Sns/SnsPostItem'
import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel'
import * as configService from '../services/config'
const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000
const SNS_PAGE_CACHE_POST_LIMIT = 200
const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__'
interface Contact { interface Contact {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
type?: 'friend' | 'former_friend' | 'sns_only' type?: 'friend' | 'former_friend' | 'sns_only'
postCount: number postCount?: number
postCountStatus: 'loading' | 'ready' | 'error'
} }
interface SnsOverviewStats { interface SnsOverviewStats {
@@ -71,12 +77,30 @@ export default function SnsPage() {
const [hasNewer, setHasNewer] = useState(false) const [hasNewer, setHasNewer] = useState(false)
const [loadingNewer, setLoadingNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false)
const postsRef = useRef<SnsPost[]>([]) const postsRef = useRef<SnsPost[]>([])
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const selectedUsernamesRef = useRef<string[]>(selectedUsernames)
const searchKeywordRef = useRef(searchKeyword)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const cacheScopeKeyRef = useRef('')
const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null)
const contactsLoadTokenRef = useRef(0)
// Sync posts ref // Sync posts ref
useEffect(() => { useEffect(() => {
postsRef.current = posts postsRef.current = posts
}, [posts]) }, [posts])
useEffect(() => {
overviewStatsRef.current = overviewStats
}, [overviewStats])
useEffect(() => {
selectedUsernamesRef.current = selectedUsernames
}, [selectedUsernames])
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
// 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动
useLayoutEffect(() => { useLayoutEffect(() => {
const snapshot = scrollAdjustmentRef.current; const snapshot = scrollAdjustmentRef.current;
@@ -100,6 +124,78 @@ export default function SnsPage() {
return `${year}-${month}-${day}` return `${year}-${month}-${day}`
} }
const isDefaultViewNow = useCallback(() => {
return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current
}, [])
const ensureSnsCacheScopeKey = useCallback(async () => {
if (cacheScopeKeyRef.current) return cacheScopeKeyRef.current
const wxid = (await configService.getMyWxid())?.trim() || SNS_PAGE_CACHE_SCOPE_FALLBACK
const scopeKey = `sns_page:${wxid}`
cacheScopeKeyRef.current = scopeKey
return scopeKey
}, [])
const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => {
if (!isDefaultViewNow()) return
try {
const scopeKey = await ensureSnsCacheScopeKey()
if (!scopeKey) return
let postsToStore = patch?.posts ?? postsRef.current
if (!patch?.posts && postsToStore.length === 0) {
const existingCache = await configService.getSnsPageCache(scopeKey)
if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) {
postsToStore = existingCache.posts as SnsPost[]
}
}
const overviewToStore = patch?.overviewStats ?? overviewStatsRef.current
await configService.setSnsPageCache(scopeKey, {
overviewStats: overviewToStore,
posts: postsToStore.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
})
} catch (error) {
console.error('Failed to persist SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey, isDefaultViewNow])
const hydrateSnsPageCache = useCallback(async () => {
try {
const scopeKey = await ensureSnsCacheScopeKey()
const cached = await configService.getSnsPageCache(scopeKey)
if (!cached) return
if (Date.now() - cached.updatedAt > SNS_PAGE_CACHE_TTL_MS) return
const cachedOverview = cached.overviewStats
if (cachedOverview) {
setOverviewStats({
totalPosts: Math.max(0, Number(cachedOverview.totalPosts || 0)),
totalFriends: Math.max(0, Number(cachedOverview.totalFriends || 0)),
earliestTime: cachedOverview.earliestTime ?? null,
latestTime: cachedOverview.latestTime ?? null
})
}
if (Array.isArray(cached.posts) && cached.posts.length > 0) {
const cachedPosts = cached.posts
.filter((raw): raw is SnsPost => {
if (!raw || typeof raw !== 'object') return false
const row = raw as Record<string, unknown>
return typeof row.id === 'string' && typeof row.createTime === 'number'
})
.slice(0, SNS_PAGE_CACHE_POST_LIMIT)
.sort((a, b) => b.createTime - a.createTime)
if (cachedPosts.length > 0) {
setPosts(cachedPosts)
setHasMore(true)
setHasNewer(false)
}
}
} catch (error) {
console.error('Failed to hydrate SNS page cache:', error)
}
}, [ensureSnsCacheScopeKey])
const loadOverviewStats = useCallback(async () => { const loadOverviewStats = useCallback(async () => {
setOverviewStatsLoading(true) setOverviewStatsLoading(true)
try { try {
@@ -129,18 +225,20 @@ export default function SnsPage() {
} }
} }
setOverviewStats({ const nextOverviewStats = {
totalPosts, totalPosts,
totalFriends, totalFriends,
earliestTime, earliestTime,
latestTime latestTime
}) }
setOverviewStats(nextOverviewStats)
void persistSnsPageCache({ overviewStats: nextOverviewStats })
} catch (error) { } catch (error) {
console.error('Failed to load SNS overview stats:', error) console.error('Failed to load SNS overview stats:', error)
} finally { } finally {
setOverviewStatsLoading(false) setOverviewStatsLoading(false)
} }
}, []) }, [persistSnsPageCache])
const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => {
const { reset = false, direction = 'older' } = options const { reset = false, direction = 'older' } = options
@@ -186,7 +284,9 @@ export default function SnsPage() {
const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) { if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev].sort((a, b) => b.createTime - a.createTime)); const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime)
setPosts(merged);
void persistSnsPageCache({ posts: merged })
} }
setHasNewer(result.timeline.length >= limit); setHasNewer(result.timeline.length >= limit);
} else { } else {
@@ -216,6 +316,7 @@ export default function SnsPage() {
if (result.success && result.timeline) { if (result.success && result.timeline) {
if (reset) { if (reset) {
setPosts(result.timeline) setPosts(result.timeline)
void persistSnsPageCache({ posts: result.timeline })
setHasMore(result.timeline.length >= limit) setHasMore(result.timeline.length >= limit)
// Check for newer items above topTs // Check for newer items above topTs
@@ -232,7 +333,9 @@ export default function SnsPage() {
} }
} else { } else {
if (result.timeline.length > 0) { if (result.timeline.length > 0) {
setPosts(prev => [...prev, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)) const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime)
setPosts(merged)
void persistSnsPageCache({ posts: merged })
} }
if (result.timeline.length < limit) { if (result.timeline.length < limit) {
setHasMore(false) setHasMore(false)
@@ -246,22 +349,18 @@ export default function SnsPage() {
setLoadingNewer(false) setLoadingNewer(false)
loadingRef.current = false loadingRef.current = false
} }
}, [selectedUsernames, searchKeyword, jumpTargetDate]) }, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames])
// Load Contacts合并好友+曾经好友+朋友圈发布者enrichSessionsContactInfo 补充头像) // Load Contacts合并好友+曾经好友+朋友圈发布者enrichSessionsContactInfo 补充头像)
const loadContacts = useCallback(async () => { const loadContacts = useCallback(async () => {
const requestToken = ++contactsLoadTokenRef.current
setContactsLoading(true) setContactsLoading(true)
try { try {
// 并行获取联系人列表、朋友圈发布者列表和每个发布者的动态条数 // 先加载联系人基础信息,再异步补齐朋友圈条数
const [contactsResult, snsResult, snsCountsResult] = await Promise.all([ const [contactsResult, snsResult] = await Promise.all([
window.electronAPI.chat.getContacts(), window.electronAPI.chat.getContacts(),
window.electronAPI.sns.getSnsUsernames(), window.electronAPI.sns.getSnsUsernames()
window.electronAPI.sns.getUserPostCounts()
]) ])
const snsPostCountMap = new Map<string, number>(
Object.entries(snsCountsResult.success ? (snsCountsResult.data || {}) : {})
.map(([username, count]) => [username, Math.max(0, Number(count || 0))])
)
// 以联系人为基础,按 username 去重 // 以联系人为基础,按 username 去重
const contactMap = new Map<string, Contact>() const contactMap = new Map<string, Contact>()
@@ -275,7 +374,7 @@ export default function SnsPage() {
displayName: c.displayName, displayName: c.displayName,
avatarUrl: c.avatarUrl, avatarUrl: c.avatarUrl,
type: c.type === 'former_friend' ? 'former_friend' : 'friend', type: c.type === 'former_friend' ? 'former_friend' : 'friend',
postCount: snsPostCountMap.get(c.username) || 0 postCountStatus: 'loading'
}) })
} }
} }
@@ -285,7 +384,7 @@ export default function SnsPage() {
if (snsResult.success && snsResult.usernames) { if (snsResult.success && snsResult.usernames) {
for (const u of snsResult.usernames) { for (const u of snsResult.usernames) {
if (!contactMap.has(u)) { if (!contactMap.has(u)) {
contactMap.set(u, { username: u, displayName: u, type: 'sns_only', postCount: snsPostCountMap.get(u) || 0 }) contactMap.set(u, { username: u, displayName: u, type: 'sns_only', postCountStatus: 'loading' })
} }
} }
} }
@@ -306,32 +405,63 @@ export default function SnsPage() {
} }
} }
if (requestToken !== contactsLoadTokenRef.current) return
setContacts(Array.from(contactMap.values())) setContacts(Array.from(contactMap.values()))
const snsCountsResult = await window.electronAPI.sns.getUserPostCounts()
if (requestToken !== contactsLoadTokenRef.current) return
if (snsCountsResult.success && snsCountsResult.data) {
const snsPostCountMap = new Map<string, number>(
Object.entries(snsCountsResult.data).map(([username, count]) => [username, Math.max(0, Number(count || 0))])
)
setContacts(prev => prev.map(contact => ({
...contact,
postCount: snsPostCountMap.get(contact.username) ?? 0,
postCountStatus: 'ready'
})))
} else {
console.error('Failed to load SNS contact post counts:', snsCountsResult.error)
setContacts(prev => prev.map(contact => ({
...contact,
postCountStatus: 'error'
})))
}
} catch (error) { } catch (error) {
if (requestToken !== contactsLoadTokenRef.current) return
console.error('Failed to load contacts:', error) console.error('Failed to load contacts:', error)
setContacts(prev => prev.map(contact => ({
...contact,
postCountStatus: 'error'
})))
} finally { } finally {
setContactsLoading(false) if (requestToken === contactsLoadTokenRef.current) {
setContactsLoading(false)
}
} }
}, []) }, [])
// Initial Load & Listeners // Initial Load & Listeners
useEffect(() => { useEffect(() => {
void hydrateSnsPageCache()
loadContacts() loadContacts()
loadOverviewStats() loadOverviewStats()
}, [loadContacts, loadOverviewStats]) }, [hydrateSnsPageCache, loadContacts, loadOverviewStats])
useEffect(() => { useEffect(() => {
const handleChange = () => { const handleChange = () => {
cacheScopeKeyRef.current = ''
// wxid changed, reset everything // wxid changed, reset everything
setPosts([]); setHasMore(true); setHasNewer(false); setPosts([]); setHasMore(true); setHasNewer(false);
setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined);
void hydrateSnsPageCache()
loadContacts(); loadContacts();
loadOverviewStats(); loadOverviewStats();
loadPosts({ reset: true }); loadPosts({ reset: true });
} }
window.addEventListener('wxid-changed', handleChange as EventListener) window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadContacts, loadOverviewStats, loadPosts]) }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts])
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -445,7 +575,11 @@ export default function SnsPage() {
}} }}
onDebug={(p) => setDebugPost(p)} onDebug={(p) => setDebugPost(p)}
onDelete={(postId) => { onDelete={(postId) => {
setPosts(prev => prev.filter(p => p.id !== postId)) setPosts(prev => {
const next = prev.filter(p => p.id !== postId)
void persistSnsPageCache({ posts: next })
return next
})
loadOverviewStats() loadOverviewStats()
}} }}
/> />

View File

@@ -38,6 +38,7 @@ 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',
SNS_PAGE_CACHE_MAP: 'snsPageCacheMap',
CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs',
CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap',
CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap', CONTACTS_AVATAR_CACHE_MAP: 'contactsAvatarCacheMap',
@@ -465,6 +466,19 @@ export interface ExportSnsStatsCacheItem {
totalFriends: number totalFriends: number
} }
export interface SnsPageOverviewCache {
totalPosts: number
totalFriends: number
earliestTime: number | null
latestTime: number | null
}
export interface SnsPageCacheItem {
updatedAt: number
overviewStats: SnsPageOverviewCache
posts: unknown[]
}
export interface ContactsListCacheContact { export interface ContactsListCacheContact {
username: string username: string
displayName: string displayName: string
@@ -576,6 +590,70 @@ 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 getSnsPageCache(scopeKey: string): Promise<SnsPageCacheItem | null> {
if (!scopeKey) return null
const value = await config.get(CONFIG_KEYS.SNS_PAGE_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 raw = rawItem as Record<string, unknown>
const rawOverview = raw.overviewStats
const rawPosts = raw.posts
if (!rawOverview || typeof rawOverview !== 'object' || !Array.isArray(rawPosts)) return null
const overviewObj = rawOverview as Record<string, unknown>
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.floor(v) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
return {
updatedAt: typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : 0,
overviewStats: {
totalPosts: Math.max(0, normalizeNumber(overviewObj.totalPosts)),
totalFriends: Math.max(0, normalizeNumber(overviewObj.totalFriends)),
earliestTime: normalizeNullableTimestamp(overviewObj.earliestTime),
latestTime: normalizeNullableTimestamp(overviewObj.latestTime)
},
posts: rawPosts
}
}
export async function setSnsPageCache(
scopeKey: string,
payload: { overviewStats: SnsPageOverviewCache; posts: unknown[] }
): Promise<void> {
if (!scopeKey) return
const current = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP)
const map = current && typeof current === 'object'
? { ...(current as Record<string, unknown>) }
: {}
const normalizeNumber = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
const normalizeNullableTimestamp = (v: unknown) => {
if (v === null || v === undefined) return null
if (typeof v === 'number' && Number.isFinite(v) && v > 0) return Math.floor(v)
return null
}
map[scopeKey] = {
updatedAt: Date.now(),
overviewStats: {
totalPosts: normalizeNumber(payload?.overviewStats?.totalPosts),
totalFriends: normalizeNumber(payload?.overviewStats?.totalFriends),
earliestTime: normalizeNullableTimestamp(payload?.overviewStats?.earliestTime),
latestTime: normalizeNullableTimestamp(payload?.overviewStats?.latestTime)
},
posts: Array.isArray(payload?.posts) ? payload.posts : []
}
await config.set(CONFIG_KEYS.SNS_PAGE_CACHE_MAP, map)
}
// 获取通讯录加载超时阈值(毫秒) // 获取通讯录加载超时阈值(毫秒)
export async function getContactsLoadTimeoutMs(): Promise<number> { export async function getContactsLoadTimeoutMs(): Promise<number> {
const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS) const value = await config.get(CONFIG_KEYS.CONTACTS_LOAD_TIMEOUT_MS)