import { useEffect, useLayoutEffect, useState, useRef, useCallback, useMemo } from 'react' import { RefreshCw, Search, X, Download, FolderOpen, FileJson, FileText, Image, CheckCircle, AlertCircle, Calendar, Info, ChevronLeft, ChevronRight, Shield, ShieldOff, Loader2 } from 'lucide-react' import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimeline' import JumpToDatePopover from '../components/JumpToDatePopover' 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__' const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200 const CONTACT_COUNT_BATCH_SIZE = 10 type ContactPostCountStatus = 'idle' | 'loading' | 'ready' interface Contact { username: string displayName: string avatarUrl?: string remark?: string nickname?: string type?: 'friend' | 'former_friend' | 'sns_only' lastSessionTimestamp?: number postCount?: number postCountStatus?: ContactPostCountStatus } interface SidebarUserProfile { wxid: string displayName: string alias?: string avatarUrl?: string } interface ContactsCountProgress { resolved: number total: number running: boolean } interface SnsOverviewStats { totalPosts: number totalFriends: number myPosts: number | null earliestTime: number | null latestTime: number | null } type OverviewStatsStatus = 'loading' | 'ready' | 'error' const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1' const readSidebarUserProfileCache = (): SidebarUserProfile | null => { try { const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY) if (!raw) return null const parsed = JSON.parse(raw) as SidebarUserProfile if (!parsed || typeof parsed !== 'object') return null return { wxid: String(parsed.wxid || '').trim(), displayName: String(parsed.displayName || '').trim(), alias: parsed.alias ? String(parsed.alias).trim() : undefined, avatarUrl: parsed.avatarUrl ? String(parsed.avatarUrl).trim() : undefined } } catch { return null } } const normalizeAccountId = (value?: string | null): string => { const trimmed = String(value || '').trim() if (!trimmed) return '' if (trimmed.toLowerCase().startsWith('wxid_')) { const match = trimmed.match(/^(wxid_[^_]+)/i) return (match?.[1] || trimmed).toLowerCase() } const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) return (suffixMatch ? suffixMatch[1] : trimmed).toLowerCase() } const normalizeNameForCompare = (value?: string | null): string => String(value || '').trim().toLowerCase() export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) const [hasMore, setHasMore] = useState(true) const loadingRef = useRef(false) const [overviewStats, setOverviewStats] = useState({ totalPosts: 0, totalFriends: 0, myPosts: null, earliestTime: null, latestTime: null }) const [overviewStatsStatus, setOverviewStatsStatus] = useState('loading') // Filter states const [searchKeyword, setSearchKeyword] = useState('') const [jumpTargetDate, setJumpTargetDate] = useState(undefined) // Contacts state const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) const [contactsCountProgress, setContactsCountProgress] = useState({ resolved: 0, total: 0, running: false }) const [currentUserProfile, setCurrentUserProfile] = useState(() => readSidebarUserProfileCache() || { wxid: '', displayName: '' }) // UI states const [debugPost, setDebugPost] = useState(null) const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) const [showJumpPopover, setShowJumpPopover] = useState(false) const [jumpPopoverDate, setJumpPopoverDate] = useState(jumpTargetDate || new Date()) const [jumpDateCounts, setJumpDateCounts] = useState>({}) const [jumpDateMessageDates, setJumpDateMessageDates] = useState>(new Set()) const [hasLoadedJumpDateCounts, setHasLoadedJumpDateCounts] = useState(false) const [loadingJumpDateCounts, setLoadingJumpDateCounts] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) const [exportFormat, setExportFormat] = useState<'json' | 'html' | 'arkmejson'>('html') const [exportFolder, setExportFolder] = useState('') const [exportImages, setExportImages] = useState(false) const [exportLivePhotos, setExportLivePhotos] = useState(false) const [exportVideos, setExportVideos] = useState(false) const [exportDateRange, setExportDateRange] = useState<{ start: string; end: string }>({ start: '', end: '' }) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState<{ current: number; total: number; status: string } | null>(null) const [exportResult, setExportResult] = useState<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string } | null>(null) const [refreshSpin, setRefreshSpin] = useState(false) const [calendarPicker, setCalendarPicker] = useState<{ field: 'start' | 'end'; month: Date } | null>(null) const [showYearMonthPicker, setShowYearMonthPicker] = useState(false) // 触发器相关状态 const [showTriggerDialog, setShowTriggerDialog] = useState(false) const [triggerInstalled, setTriggerInstalled] = useState(null) const [triggerLoading, setTriggerLoading] = useState(false) const [triggerMessage, setTriggerMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null) const postsContainerRef = useRef(null) const jumpCalendarWrapRef = useRef(null) const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) const contactsRef = useRef([]) const overviewStatsRef = useRef(overviewStats) const overviewStatsStatusRef = useRef(overviewStatsStatus) const searchKeywordRef = useRef(searchKeyword) const jumpTargetDateRef = useRef(jumpTargetDate) const cacheScopeKeyRef = useRef('') const snsUserPostCountsCacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) const contactsCountBatchTimerRef = useRef(null) const jumpDateCountsCacheRef = useRef>>(new Map()) const jumpDateRequestSeqRef = useRef(0) // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) useEffect(() => { contactsRef.current = contacts }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) useEffect(() => { overviewStatsStatusRef.current = overviewStatsStatus }, [overviewStatsStatus]) useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) useEffect(() => { if (!showJumpPopover) { setJumpPopoverDate(jumpTargetDate || new Date()) } }, [jumpTargetDate, showJumpPopover]) useEffect(() => { if (!showJumpPopover) return const handleClickOutside = (event: MouseEvent) => { if (!jumpCalendarWrapRef.current) return if (jumpCalendarWrapRef.current.contains(event.target as Node)) return setShowJumpPopover(false) } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [showJumpPopover]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; if (snapshot && postsContainerRef.current) { const container = postsContainerRef.current; const addedHeight = container.scrollHeight - snapshot.scrollHeight; if (addedHeight > 0) { container.scrollTop = snapshot.scrollTop + addedHeight; } scrollAdjustmentRef.current = null; } }, [posts]) const formatDateOnly = (timestamp: number | null): string => { if (!timestamp || timestamp <= 0) return '--' const date = new Date(timestamp * 1000) if (Number.isNaN(date.getTime())) return '--' const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } const decodeHtmlEntities = (text: string): string => { if (!text) return '' return text .replace(//g, '$1') .replace(/&/gi, '&') .replace(/</gi, '<') .replace(/>/gi, '>') .replace(/"/gi, '"') .replace(/'/gi, "'") .trim() } const normalizePostCount = useCallback((value: unknown): number => { const numeric = Number(value) if (!Number.isFinite(numeric)) return 0 return Math.max(0, Math.floor(numeric)) }, []) const toMonthKey = useCallback((date: Date) => { return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` }, []) const toDateKey = useCallback((timestampSeconds: number) => { const date = new Date(timestampSeconds * 1000) const year = date.getFullYear() const month = String(date.getMonth() + 1).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0') return `${year}-${month}-${day}` }, []) const applyJumpDateCounts = useCallback((counts: Record) => { setJumpDateCounts(counts) setJumpDateMessageDates(new Set(Object.keys(counts))) setHasLoadedJumpDateCounts(true) }, []) const loadJumpDateCounts = useCallback(async (monthDate: Date) => { const monthKey = toMonthKey(monthDate) const cached = jumpDateCountsCacheRef.current.get(monthKey) if (cached) { applyJumpDateCounts(cached) setLoadingJumpDateCounts(false) return } const requestSeq = ++jumpDateRequestSeqRef.current setLoadingJumpDateCounts(true) setHasLoadedJumpDateCounts(false) const year = monthDate.getFullYear() const month = monthDate.getMonth() const monthStart = new Date(year, month, 1, 0, 0, 0, 0) const monthEnd = new Date(year, month + 1, 0, 23, 59, 59, 999) const startTime = Math.floor(monthStart.getTime() / 1000) const endTime = Math.floor(monthEnd.getTime() / 1000) const pageSize = 200 let offset = 0 const counts: Record = {} try { while (true) { const result = await window.electronAPI.sns.getTimeline(pageSize, offset, [], '', startTime, endTime) if (!result?.success || !Array.isArray(result.timeline) || result.timeline.length === 0) { break } result.timeline.forEach((post) => { const key = toDateKey(Number(post.createTime || 0)) if (!key) return counts[key] = (counts[key] || 0) + 1 }) if (result.timeline.length < pageSize) break offset += pageSize } if (requestSeq !== jumpDateRequestSeqRef.current) return jumpDateCountsCacheRef.current.set(monthKey, counts) applyJumpDateCounts(counts) } catch (error) { console.error('加载朋友圈按日条数失败:', error) if (requestSeq !== jumpDateRequestSeqRef.current) return setJumpDateCounts({}) setJumpDateMessageDates(new Set()) setHasLoadedJumpDateCounts(true) } finally { if (requestSeq === jumpDateRequestSeqRef.current) { setLoadingJumpDateCounts(false) } } }, [applyJumpDateCounts, toDateKey, toMonthKey]) const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => { const aReady = a.postCountStatus === 'ready' const bReady = b.postCountStatus === 'ready' if (aReady && bReady) { const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount) if (countDiff !== 0) return countDiff } else if (aReady !== bReady) { return aReady ? -1 : 1 } const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0) if (tsDiff !== 0) return tsDiff return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN') }, [normalizePostCount]) const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => { return [...input].sort(compareContactsForRanking) }, [compareContactsForRanking]) const resolvedCurrentUserContact = useMemo(() => { const normalizedWxid = normalizeAccountId(currentUserProfile.wxid) const normalizedAlias = normalizeAccountId(currentUserProfile.alias) const normalizedDisplayName = normalizeNameForCompare(currentUserProfile.displayName) if (normalizedWxid) { const exactByUsername = contacts.find((contact) => normalizeAccountId(contact.username) === normalizedWxid) if (exactByUsername) return exactByUsername } if (normalizedAlias) { const exactByAliasLikeName = contacts.find((contact) => { const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare) return candidates.includes(normalizedAlias) }) if (exactByAliasLikeName) return exactByAliasLikeName } if (!normalizedDisplayName) return null return contacts.find((contact) => { const candidates = [contact.displayName, contact.remark, contact.nickname].map(normalizeNameForCompare) return candidates.includes(normalizedDisplayName) }) || null }, [contacts, currentUserProfile.alias, currentUserProfile.displayName, currentUserProfile.wxid]) const currentTimelineTargetContact = useMemo(() => { const normalizedTargetUsername = String(authorTimelineTarget?.username || '').trim() if (!normalizedTargetUsername) return null return contacts.find((contact) => contact.username === normalizedTargetUsername) || null }, [authorTimelineTarget, contacts]) const myTimelineCount = useMemo(() => { if (resolvedCurrentUserContact?.postCountStatus === 'ready' && typeof resolvedCurrentUserContact.postCount === 'number') { return normalizePostCount(resolvedCurrentUserContact.postCount) } return null }, [normalizePostCount, resolvedCurrentUserContact]) const myTimelineCountLoading = Boolean( resolvedCurrentUserContact ? resolvedCurrentUserContact.postCountStatus !== 'ready' : overviewStatsStatus === 'loading' || contactsLoading ) const openCurrentUserTimeline = useCallback(() => { if (!resolvedCurrentUserContact) return setAuthorTimelineTarget({ username: resolvedCurrentUserContact.username, displayName: resolvedCurrentUserContact.displayName || currentUserProfile.displayName || resolvedCurrentUserContact.username, avatarUrl: resolvedCurrentUserContact.avatarUrl || currentUserProfile.avatarUrl }) }, [currentUserProfile.avatarUrl, currentUserProfile.displayName, resolvedCurrentUserContact]) const isDefaultViewNow = useCallback(() => { return !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 ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => { if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current const [wxidRaw, dbPathRaw] = await Promise.all([ configService.getMyWxid(), configService.getDbPath() ]) const wxid = String(wxidRaw || '').trim() const dbPath = String(dbPathRaw || '').trim() const scopeKey = (dbPath || wxid) ? `${dbPath}::${wxid}` : 'default' snsUserPostCountsCacheScopeKeyRef.current = scopeKey return scopeKey }, []) const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => { if (!isDefaultViewNow()) return try { const scopeKey = await ensureSnsCacheScopeKey() if (!scopeKey) return const existingCache = await configService.getSnsPageCache(scopeKey) let postsToStore = patch?.posts ?? postsRef.current if (!patch?.posts && postsToStore.length === 0) { if (existingCache && Array.isArray(existingCache.posts) && existingCache.posts.length > 0) { postsToStore = existingCache.posts as SnsPost[] } } const overviewToStore = patch?.overviewStats ?? (overviewStatsStatusRef.current === 'ready' ? overviewStatsRef.current : existingCache?.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) { const cachedTotalPosts = Math.max(0, Number(cachedOverview.totalPosts || 0)) const cachedTotalFriends = Math.max(0, Number(cachedOverview.totalFriends || 0)) const hasCachedPosts = Array.isArray(cached.posts) && cached.posts.length > 0 const hasOverviewData = cachedTotalPosts > 0 || cachedTotalFriends > 0 setOverviewStats({ totalPosts: cachedTotalPosts, totalFriends: cachedTotalFriends, myPosts: typeof cachedOverview.myPosts === 'number' && Number.isFinite(cachedOverview.myPosts) && cachedOverview.myPosts >= 0 ? Math.floor(cachedOverview.myPosts) : null, earliestTime: cachedOverview.earliestTime ?? null, latestTime: cachedOverview.latestTime ?? null }) // 只有明确有统计值(或确实无帖子)时才把缓存视为 ready,避免历史异常 0 卡住显示。 setOverviewStatsStatus(hasOverviewData || !hasCachedPosts ? 'ready' : 'loading') } 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 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 () => { setOverviewStatsStatus('loading') try { const statsResult = await window.electronAPI.sns.getExportStats() if (!statsResult.success || !statsResult.data) { throw new Error(statsResult.error || '获取朋友圈统计失败') } const totalPosts = Math.max(0, Number(statsResult.data.totalPosts || 0)) const totalFriends = Math.max(0, Number(statsResult.data.totalFriends || 0)) const myPosts = (typeof statsResult.data.myPosts === 'number' && Number.isFinite(statsResult.data.myPosts) && statsResult.data.myPosts >= 0) ? Math.floor(statsResult.data.myPosts) : null let earliestTime: number | null = null let latestTime: number | null = null if (totalPosts > 0) { const [latestResult, earliestResult] = await Promise.all([ window.electronAPI.sns.getTimeline(1, 0), window.electronAPI.sns.getTimeline(1, Math.max(totalPosts - 1, 0)) ]) const latestTs = Number(latestResult.timeline?.[0]?.createTime || 0) const earliestTs = Number(earliestResult.timeline?.[0]?.createTime || 0) if (latestResult.success && Number.isFinite(latestTs) && latestTs > 0) { latestTime = Math.floor(latestTs) } if (earliestResult.success && Number.isFinite(earliestTs) && earliestTs > 0) { earliestTime = Math.floor(earliestTs) } } const nextOverviewStats = { totalPosts, totalFriends, myPosts, earliestTime, latestTime } setOverviewStats(nextOverviewStats) setOverviewStatsStatus('ready') void persistSnsPageCache({ overviewStats: nextOverviewStats }) } catch (error) { console.error('Failed to load SNS overview stats:', error) setOverviewStatsStatus('error') } }, [persistSnsPageCache]) const renderOverviewRangeText = () => { if (overviewStatsStatus === 'error') { return ( ) } if (overviewStatsStatus === 'loading') { return '统计中...' } return `${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)}` } const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { const { reset = false, direction = 'older' } = options if (loadingRef.current) return loadingRef.current = true if (direction === 'newer') setLoadingNewer(true) else setLoading(true) try { const limit = 20 let startTs: number | undefined = undefined let endTs: number | undefined = undefined if (reset) { // If jumping to date, set endTs to end of that day if (jumpTargetDate) { endTs = Math.floor(jumpTargetDate.getTime() / 1000) + 86399 } } else if (direction === 'newer') { const currentPosts = postsRef.current if (currentPosts.length > 0) { const topTs = currentPosts[0].createTime const result = await window.electronAPI.sns.getTimeline( limit, 0, undefined, searchKeyword, topTs + 1, undefined ); if (result.success && result.timeline && result.timeline.length > 0) { if (postsContainerRef.current) { scrollAdjustmentRef.current = { scrollHeight: postsContainerRef.current.scrollHeight, scrollTop: postsContainerRef.current.scrollTop }; } const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id)); if (uniqueNewer.length > 0) { const merged = [...uniqueNewer, ...currentPosts].sort((a, b) => b.createTime - a.createTime) setPosts(merged); void persistSnsPageCache({ posts: merged }) } setHasNewer(result.timeline.length >= limit); } else { setHasNewer(false); } } setLoadingNewer(false); loadingRef.current = false; return; } else { // Loading older const currentPosts = postsRef.current if (currentPosts.length > 0) { endTs = currentPosts[currentPosts.length - 1].createTime - 1 } } const result = await window.electronAPI.sns.getTimeline( limit, 0, undefined, searchKeyword, startTs, // default undefined endTs ) if (result.success && result.timeline) { if (reset) { setPosts(result.timeline) void persistSnsPageCache({ posts: result.timeline }) setHasMore(result.timeline.length >= limit) // Check for newer items above topTs const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { const checkResult = await window.electronAPI.sns.getTimeline(1, 0, undefined, searchKeyword, topTs + 1, undefined); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); } else { setHasNewer(false); } if (postsContainerRef.current) { postsContainerRef.current.scrollTop = 0 } } else { if (result.timeline.length > 0) { const merged = [...postsRef.current, ...result.timeline!].sort((a, b) => b.createTime - a.createTime) setPosts(merged) void persistSnsPageCache({ posts: merged }) } if (result.timeline.length < limit) { setHasMore(false) } } } } catch (error) { console.error('Failed to load SNS timeline:', error) } finally { setLoading(false) setLoadingNewer(false) loadingRef.current = false } }, [jumpTargetDate, persistSnsPageCache, searchKeyword]) const stopContactsCountHydration = useCallback((resetProgress = false) => { contactsCountHydrationTokenRef.current += 1 if (contactsCountBatchTimerRef.current) { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } if (resetProgress) { setContactsCountProgress({ resolved: 0, total: 0, running: false }) } else { setContactsCountProgress((prev) => ({ ...prev, running: false })) } }, []) const hydrateContactPostCounts = useCallback(async ( usernames: string[], options?: { force?: boolean; readyUsernames?: Set } ) => { const force = options?.force === true const targets = usernames .map((username) => String(username || '').trim()) .filter(Boolean) stopContactsCountHydration(true) if (targets.length === 0) return const readySet = options?.readyUsernames || new Set( contactsRef.current .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') .map((contact) => contact.username) ) const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username)) const runToken = ++contactsCountHydrationTokenRef.current const totalTargets = targets.length const targetSet = new Set(pendingTargets) if (pendingTargets.length > 0) { setContacts((prev) => { let changed = false const next = prev.map((contact) => { if (!targetSet.has(contact.username)) return contact if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact changed = true return { ...contact, postCount: force ? undefined : contact.postCount, postCountStatus: 'loading' as ContactPostCountStatus } }) return changed ? sortContactsForRanking(next) : prev }) } const preResolved = Math.max(0, totalTargets - pendingTargets.length) setContactsCountProgress({ resolved: preResolved, total: totalTargets, running: pendingTargets.length > 0 }) if (pendingTargets.length === 0) return let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() if (runToken !== contactsCountHydrationTokenRef.current) return if (result.success && result.counts) { normalizedCounts = Object.fromEntries( Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) ) void (async () => { try { const scopeKey = await ensureSnsUserPostCountsCacheScopeKey() await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) } catch (cacheError) { console.error('Failed to persist SNS user post counts cache:', cacheError) } })() } } catch (error) { console.error('Failed to load contact post counts:', error) } let resolved = preResolved let cursor = 0 const applyBatch = () => { if (runToken !== contactsCountHydrationTokenRef.current) return const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) if (batch.length === 0) { setContactsCountProgress({ resolved: totalTargets, total: totalTargets, running: false }) contactsCountBatchTimerRef.current = null return } const batchSet = new Set(batch) setContacts((prev) => { let changed = false const next = prev.map((contact) => { if (!batchSet.has(contact.username)) return contact const nextCount = normalizePostCount(normalizedCounts[contact.username]) if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact changed = true return { ...contact, postCount: nextCount, postCountStatus: 'ready' as ContactPostCountStatus } }) return changed ? sortContactsForRanking(next) : prev }) resolved += batch.length cursor += batch.length setContactsCountProgress({ resolved, total: totalTargets, running: resolved < totalTargets }) if (cursor < totalTargets) { contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) } else { contactsCountBatchTimerRef.current = null } } applyBatch() }, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration]) // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { const requestToken = ++contactsLoadTokenRef.current stopContactsCountHydration(true) setContactsLoading(true) try { const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([ configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey), configService.getContactsListCache(snsPostCountsScopeKey), configService.getContactsAvatarCache(snsPostCountsScopeKey) ]) const cachedPostCounts = cachedPostCountsItem?.counts || {} const cachedAvatarMap = cachedAvatarItem?.avatars || {} const cachedContacts = (cachedContactsItem?.contacts || []) .filter((contact) => contact.type === 'friend' || contact.type === 'former_friend') .map((contact) => { const cachedCount = cachedPostCounts[contact.username] const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) return { username: contact.username, displayName: contact.displayName || contact.username, avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl, remark: contact.remark, nickname: contact.nickname, type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend', lastSessionTimestamp: 0, postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus } }) if (requestToken !== contactsLoadTokenRef.current) return if (cachedContacts.length > 0) { const cachedContactsSorted = sortContactsForRanking(cachedContacts) setContacts(cachedContactsSorted) setContactsLoading(false) const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length setContactsCountProgress({ resolved: cachedReadyCount, total: cachedContactsSorted.length, running: cachedReadyCount < cachedContactsSorted.length }) } const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() ]) const contactMap = new Map() const sessionTimestampMap = new Map() if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { for (const session of sessionsResult.sessions) { const username = String(session?.username || '').trim() if (!username) continue const ts = Math.max( Number(session?.sortTimestamp || 0), Number(session?.lastTimestamp || 0) ) const prevTs = Number(sessionTimestampMap.get(username) || 0) if (ts > prevTs) { sessionTimestampMap.set(username, ts) } } } if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { if (c.type === 'friend' || c.type === 'former_friend') { const cachedCount = cachedPostCounts[c.username] const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) contactMap.set(c.username, { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, remark: c.remark, nickname: c.nickname, type: c.type === 'former_friend' ? 'former_friend' : 'friend', lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, postCountStatus: hasCachedCount ? 'ready' : 'idle' }) } } } let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) const readyUsernames = new Set( contactsList .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') .map((contact) => contact.username) ) void hydrateContactPostCounts( contactsList.map(contact => contact.username), { readyUsernames } ) const allUsernames = contactsList.map(c => c.username) // 用 enrichSessionsContactInfo 统一补充头像和显示名 if (allUsernames.length > 0) { const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (enriched.success && enriched.contacts) { contactsList = contactsList.map((contact) => { const extra = enriched.contacts?.[contact.username] if (!extra) return contact return { ...contact, displayName: extra.displayName || contact.displayName, avatarUrl: extra.avatarUrl || contact.avatarUrl } }) if (requestToken !== contactsLoadTokenRef.current) return setContacts((prev) => { const prevMap = new Map(prev.map((contact) => [contact.username, contact])) const merged = contactsList.map((contact) => { const previous = prevMap.get(contact.username) return { ...contact, lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp, postCount: previous?.postCount, postCountStatus: previous?.postCountStatus ?? contact.postCountStatus } }) return sortContactsForRanking(merged) }) } } } catch (error) { if (requestToken !== contactsLoadTokenRef.current) return console.error('Failed to load contacts:', error) stopContactsCountHydration(true) } finally { if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) } } }, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { setAuthorTimelineTarget(null) }, []) const openAuthorTimeline = useCallback((post: SnsPost) => { setAuthorTimelineTarget({ username: post.username, displayName: decodeHtmlEntities(post.nickname || '') || post.username, avatarUrl: post.avatarUrl }) }, [decodeHtmlEntities]) const openContactTimeline = useCallback((contact: Contact) => { setAuthorTimelineTarget({ username: contact.username, displayName: contact.displayName || contact.username, avatarUrl: contact.avatarUrl }) }, []) const handlePostDelete = useCallback((postId: string, username: string) => { setPosts(prev => { const next = prev.filter(p => p.id !== postId) void persistSnsPageCache({ posts: next }) return next }) void loadOverviewStats() }, [loadOverviewStats, persistSnsPageCache]) // Initial Load & Listeners useEffect(() => { void hydrateSnsPageCache() loadContacts() loadOverviewStats() }, [hydrateSnsPageCache, loadContacts, loadOverviewStats]) useEffect(() => { const syncCurrentUserProfile = async () => { const cachedProfile = readSidebarUserProfileCache() if (cachedProfile) { setCurrentUserProfile((prev) => ({ wxid: cachedProfile.wxid || prev.wxid, displayName: cachedProfile.displayName || prev.displayName, alias: cachedProfile.alias || prev.alias, avatarUrl: cachedProfile.avatarUrl || prev.avatarUrl })) } try { const wxidRaw = await configService.getMyWxid() const resolvedWxid = normalizeAccountId(wxidRaw) || String(wxidRaw || '').trim() if (!resolvedWxid && !cachedProfile) return setCurrentUserProfile((prev) => ({ wxid: resolvedWxid || prev.wxid, displayName: prev.displayName || cachedProfile?.displayName || resolvedWxid || '未识别用户', alias: prev.alias || cachedProfile?.alias, avatarUrl: prev.avatarUrl || cachedProfile?.avatarUrl })) } catch (error) { console.error('Failed to sync current sidebar user profile:', error) } } void syncCurrentUserProfile() const handleChange = () => { void syncCurrentUserProfile() } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, []) useEffect(() => { return () => { contactsCountHydrationTokenRef.current += 1 if (contactsCountBatchTimerRef.current) { window.clearTimeout(contactsCountBatchTimerRef.current) contactsCountBatchTimerRef.current = null } } }, []) useEffect(() => { const handleChange = () => { cacheScopeKeyRef.current = '' snsUserPostCountsCacheScopeKeyRef.current = '' // wxid changed, reset everything stopContactsCountHydration(true) setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); setSearchKeyword(''); setJumpTargetDate(undefined); void hydrateSnsPageCache() loadContacts(); loadOverviewStats(); loadPosts({ reset: true }); } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration]) useEffect(() => { const timer = setTimeout(() => { loadPosts({ reset: true }) }, 500) return () => clearTimeout(timer) }, [searchKeyword, jumpTargetDate, loadPosts]) const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { loadPosts({ direction: 'older' }) } if (scrollTop < 10 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } const handleWheel = (e: React.WheelEvent) => { const container = postsContainerRef.current if (!container) return if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { loadPosts({ direction: 'newer' }) } } return (

朋友圈

{overviewStatsStatus === 'loading' ? '共 统计中...' : `共 ${overviewStats.totalPosts.toLocaleString('zh-CN')} 条`}
{renderOverviewRangeText()}
setShowJumpPopover(false)} onMonthChange={(date) => { setJumpPopoverDate(date) void loadJumpDateCounts(date) }} onSelect={(date) => { setJumpPopoverDate(date) setJumpTargetDate(date) }} messageDates={jumpDateMessageDates} hasLoadedMessageDates={hasLoadedJumpDateCounts} messageDateCounts={jumpDateCounts} loadingDateCounts={loadingJumpDateCounts} />
{loadingNewer && (
正在检查更新的动态...
)} {!loadingNewer && hasNewer && (
loadPosts({ direction: 'newer' })}> 有新动态,点击查看
)}
{posts.map(post => ( { if (isVideo) { void window.electronAPI.window.openVideoPlayerWindow(src) } else { void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) } }} onDebug={(p) => setDebugPost(p)} onDelete={handlePostDelete} onOpenAuthorPosts={openAuthorTimeline} /> ))}
{loading && posts.length === 0 && (
正在加载朋友圈...
)} {loading && posts.length > 0 && (
正在加载更多...
)} {!hasMore && posts.length > 0 && (
或许过往已无可溯洄,但好在还有可以与你相遇的明天
)} {!loading && posts.length === 0 && (

未找到相关动态

{(searchKeyword || jumpTargetDate) && ( )}
)}
{/* Dialogs and Overlays */} {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}>

原始数据

                                {JSON.stringify(debugPost, null, 2)}
                            
)} {/* 朋友圈防删除插件对话框 */} {showTriggerDialog && (
{ setShowTriggerDialog(false); setTriggerMessage(null) }}>
e.stopPropagation()}> {/* 顶部图标区 */}
{triggerLoading ? : triggerInstalled ? : }
朋友圈防删除
{triggerLoading ? '检查中…' : triggerInstalled ? '已启用' : '未启用'}
{/* 说明 */}
启用后,WeFlow将拦截朋友圈删除操作
已同步的动态不会从本地数据库中消失
新的动态仍可正常同步。
{/* 操作反馈 */} {triggerMessage && (
{triggerMessage.type === 'success' ? : } {triggerMessage.text}
)} {/* 操作按钮 */}
{!triggerInstalled ? ( ) : ( )}
)} {/* 导出对话框 */} {showExportDialog && (
!isExporting && setShowExportDialog(false)}>
e.stopPropagation()}>

导出朋友圈

{/* 筛选条件提示 */} {searchKeyword && (
筛选导出 {searchKeyword && 关键词: "{searchKeyword}"}
)} {!exportResult ? ( <> {/* 格式选择 */}
{/* 输出路径 */}
{/* 时间范围 */}
{ if (!isExporting) setCalendarPicker(prev => prev?.field === 'start' ? null : { field: 'start', month: exportDateRange.start ? new Date(exportDateRange.start) : new Date() }) }}> {exportDateRange.start || '开始日期'} {exportDateRange.start && ( { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, start: '' })) }} /> )}
{ if (!isExporting) setCalendarPicker(prev => prev?.field === 'end' ? null : { field: 'end', month: exportDateRange.end ? new Date(exportDateRange.end) : new Date() }) }}> {exportDateRange.end || '结束日期'} {exportDateRange.end && ( { e.stopPropagation(); setExportDateRange(prev => ({ ...prev, end: '' })) }} /> )}
{/* 媒体导出 */}

全不勾选时仅导出文本信息,不导出媒体文件

{/* 同步提示 */}
将同步主页面的关键词搜索
{/* 进度条 */} {isExporting && exportProgress && (
0 ? `${Math.round((exportProgress.current / exportProgress.total) * 100)}%` : '100%' }} />
{exportProgress.status}
)} {/* 操作按钮 */}
) : ( /* 导出结果 */
{exportResult.success ? ( <>

导出成功

共导出 {exportResult.postCount} 条动态{exportResult.mediaCount ? `,${exportResult.mediaCount} 个媒体文件` : ''}

) : ( <>

导出失败

{exportResult.error}

)}
)}
)} {/* 日期选择弹窗 */} {calendarPicker && (
{ setCalendarPicker(null); setShowYearMonthPicker(false) }}>
e.stopPropagation()}>

选择{calendarPicker.field === 'start' ? '开始' : '结束'}日期

setShowYearMonthPicker(!showYearMonthPicker)}> {calendarPicker.month.getFullYear()}年{calendarPicker.month.getMonth() + 1}月
{showYearMonthPicker ? (
{calendarPicker.month.getFullYear()}年
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => ( ))}
) : ( <>
{['日', '一', '二', '三', '四', '五', '六'].map(d =>
{d}
)}
{(() => { const y = calendarPicker.month.getFullYear() const m = calendarPicker.month.getMonth() const firstDay = new Date(y, m, 1).getDay() const daysInMonth = new Date(y, m + 1, 0).getDate() const cells: (number | null)[] = [] for (let i = 0; i < firstDay; i++) cells.push(null) for (let i = 1; i <= daysInMonth; i++) cells.push(i) const today = new Date() return cells.map((day, i) => { if (day === null) return
const dateStr = `${y}-${String(m + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}` const isToday = day === today.getDate() && m === today.getMonth() && y === today.getFullYear() const currentVal = calendarPicker.field === 'start' ? exportDateRange.start : exportDateRange.end const isSelected = dateStr === currentVal return (
{ setExportDateRange(prev => ({ ...prev, [calendarPicker.field]: dateStr })) setCalendarPicker(null) }} >{day}
) }) })()}
)}
)}
) }