diff --git a/electron/main.ts b/electron/main.ts index 14bf1e6..4638662 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1032,6 +1032,10 @@ function registerIpcHandlers() { return snsService.getExportStats() }) + ipcMain.handle('sns:getExportStatsFast', async () => { + return snsService.getExportStatsFast() + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index 43a478f..a26c46b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -290,6 +290,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 67984e9..67de6e0 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -196,6 +196,9 @@ class ChatService { // 缓存会话表信息,避免每次查询 private sessionTablesCache = new Map>() private readonly sessionTablesCacheTtl = 300000 // 5分钟 + private sessionMessageCountCache = new Map() + private sessionMessageCountCacheScope = '' + private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -795,13 +798,35 @@ class ChatService { return { success: true, counts: {} } } + this.refreshSessionMessageCountCacheScope() const counts: Record = {} - await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => { + const now = Date.now() + const pendingSessionIds: string[] = [] + + for (const sessionId of normalizedSessionIds) { + const cached = this.sessionMessageCountCache.get(sessionId) + if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) { + counts[sessionId] = cached.count + } else { + pendingSessionIds.push(sessionId) + } + } + + await this.forEachWithConcurrency(pendingSessionIds, 16, async (sessionId) => { try { const result = await wcdbService.getMessageCount(sessionId) - counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0 + const nextCount = result.success && typeof result.count === 'number' ? result.count : 0 + counts[sessionId] = nextCount + this.sessionMessageCountCache.set(sessionId, { + count: nextCount, + updatedAt: Date.now() + }) } catch { counts[sessionId] = 0 + this.sessionMessageCountCache.set(sessionId, { + count: 0, + updatedAt: Date.now() + }) } }) @@ -1455,6 +1480,15 @@ class ChatService { await Promise.all(runners) } + private refreshSessionMessageCountCacheScope(): void { + const dbPath = String(this.configService.get('dbPath') || '') + const myWxid = String(this.configService.get('myWxid') || '') + const scope = `${dbPath}::${myWxid}` + if (scope === this.sessionMessageCountCacheScope) return + this.sessionMessageCountCacheScope = scope + this.sessionMessageCountCache.clear() + } + private async collectSessionExportStats( sessionId: string, selfIdentitySet: Set diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9484cdb..369a003 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -229,6 +229,10 @@ class SnsService { private configService: ConfigService private contactCache: ContactCacheService private imageCache = new Map() + private exportStatsCache: { totalPosts: number; totalFriends: number; updatedAt: number } | null = null + private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private lastTimelineFallbackAt = 0 + private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 constructor() { this.configService = new ConfigService() @@ -403,38 +407,66 @@ class SnsService { return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) } } - async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { - try { - let totalPosts = 0 - let totalFriends = 0 + private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> { + let totalPosts = 0 + let totalFriends = 0 - const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') - if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { - totalPosts = this.parseCountValue(postCountResult.rows[0]) - } + const postCountResult = await wcdbService.execQuery('sns', null, 'SELECT COUNT(1) AS total FROM SnsTimeLine') + if (postCountResult.success && postCountResult.rows && postCountResult.rows.length > 0) { + totalPosts = this.parseCountValue(postCountResult.rows[0]) + } - if (totalPosts > 0) { - const friendCountPrimary = await wcdbService.execQuery( + if (totalPosts > 0) { + const friendCountPrimary = await wcdbService.execQuery( + 'sns', + null, + "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + ) + if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) + } else { + const friendCountFallback = await wcdbService.execQuery( 'sns', null, - "SELECT COUNT(DISTINCT user_name) AS total FROM SnsTimeLine WHERE user_name IS NOT NULL AND user_name <> ''" + "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" ) - if (friendCountPrimary.success && friendCountPrimary.rows && friendCountPrimary.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountPrimary.rows[0]) - } else { - const friendCountFallback = await wcdbService.execQuery( - 'sns', - null, - "SELECT COUNT(DISTINCT userName) AS total FROM SnsTimeLine WHERE userName IS NOT NULL AND userName <> ''" - ) - if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { - totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + if (friendCountFallback.success && friendCountFallback.rows && friendCountFallback.rows.length > 0) { + totalFriends = this.parseCountValue(friendCountFallback.rows[0]) + } + } + } + + return { totalPosts, totalFriends } + } + + async getExportStats(options?: { + allowTimelineFallback?: boolean + preferCache?: boolean + }): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + const allowTimelineFallback = options?.allowTimelineFallback ?? true + const preferCache = options?.preferCache ?? false + const now = Date.now() + + try { + if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends } } } - // 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。 - if (totalPosts <= 0 || totalFriends <= 0) { + let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount() + + // 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。 + if ( + allowTimelineFallback && + (totalPosts <= 0 || totalFriends <= 0) && + now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs + ) { + this.lastTimelineFallbackAt = now const timelineStats = await this.getExportStatsFromTimeline() if (timelineStats.totalPosts > 0) { totalPosts = timelineStats.totalPosts @@ -444,12 +476,34 @@ class SnsService { } } + this.exportStatsCache = { + totalPosts, + totalFriends, + updatedAt: Date.now() + } + return { success: true, data: { totalPosts, totalFriends } } } catch (e) { + if (this.exportStatsCache) { + return { + success: true, + data: { + totalPosts: this.exportStatsCache.totalPosts, + totalFriends: this.exportStatsCache.totalFriends + } + } + } return { success: false, error: String(e) } } } + async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> { + return this.getExportStats({ + allowTimelineFallback: false, + preferCache: true + }) + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 671c8e2..415a93a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -237,13 +237,15 @@ const timestampOrDash = (timestamp?: number): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` -const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220 -const MESSAGE_COUNT_BACKGROUND_BATCH = 180 -const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100 +const MESSAGE_COUNT_VIEWPORT_PREFETCH = 120 +const MESSAGE_COUNT_BACKGROUND_BATCH = 90 +const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 90 const METRICS_VIEWPORT_PREFETCH = 90 const METRICS_BACKGROUND_BATCH = 40 const METRICS_BACKGROUND_INTERVAL_MS = 220 const CONTACT_ENRICH_TIMEOUT_MS = 7000 +const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000 +const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null @@ -371,11 +373,13 @@ function ExportPage() { totalPosts: 0, totalFriends: 0 }) + const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const progressUnsubscribeRef = useRef<(() => void) | null>(null) const runningTaskIdRef = useRef(null) const tasksRef = useRef([]) + const hasSeededSnsStatsRef = useRef(false) const sessionMessageCountsRef = useRef>({}) const sessionMetricsRef = useRef>({}) const sessionLoadTokenRef = useRef(0) @@ -383,11 +387,18 @@ function ExportPage() { const loadingMetricsRef = useRef>(new Set()) const preselectAppliedRef = useRef(false) const visibleSessionsRef = useRef([]) + const exportCacheScopeRef = useRef('default') + const exportCacheScopeReadyRef = useRef(false) + const persistSessionCountTimerRef = useRef(null) useEffect(() => { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + hasSeededSnsStatsRef.current = hasSeededSnsStats + }, [hasSeededSnsStats]) + useEffect(() => { sessionMessageCountsRef.current = sessionMessageCounts }, [sessionMessageCounts]) @@ -396,6 +407,30 @@ function ExportPage() { sessionMetricsRef.current = sessionMetrics }, [sessionMetrics]) + useEffect(() => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + + if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return + + const countSize = Object.keys(sessionMessageCounts).length + if (countSize === 0) return + + persistSessionCountTimerRef.current = window.setTimeout(() => { + void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts) + persistSessionCountTimerRef.current = null + }, 900) + + return () => { + if (persistSessionCountTimerRef.current) { + window.clearTimeout(persistSessionCountTimerRef.current) + persistSessionCountTimerRef.current = null + } + } + }, [sessionMessageCounts, isBaseConfigLoading]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -416,7 +451,7 @@ function ExportPage() { const loadBaseConfig = useCallback(async () => { setIsBaseConfigLoading(true) try { - const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([ + const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([ configService.getExportPath(), configService.getExportDefaultFormat(), configService.getExportDefaultMedia(), @@ -427,7 +462,17 @@ function ExportPage() { configService.getExportWriteLayout(), configService.getExportLastSessionRunMap(), configService.getExportLastContentRunMap(), - configService.getExportLastSnsPostCount() + configService.getExportLastSnsPostCount(), + configService.getMyWxid(), + configService.getDbPath() + ]) + const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default' + exportCacheScopeRef.current = exportCacheScope + exportCacheScopeReadyRef.current = true + + const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([ + configService.getExportSessionMessageCountCache(exportCacheScope), + configService.getExportSnsStatsCache(exportCacheScope) ]) if (savedPath) { @@ -442,6 +487,19 @@ function ExportPage() { setLastExportByContent(savedContentMap) setLastSnsExportPostCount(savedSnsPostCount) + if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) { + setSessionMessageCounts(cachedSessionCountMap.counts || {}) + } + + if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) { + setSnsStats({ + totalPosts: cachedSnsStats.totalPosts || 0, + totalFriends: cachedSnsStats.totalFriends || 0 + }) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + } + const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns setOptions(prev => ({ ...prev, @@ -473,20 +531,52 @@ function ExportPage() { } }, []) - const loadSnsStats = useCallback(async () => { - setIsSnsStatsLoading(true) + const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => { + if (!options?.silent) { + setIsSnsStatsLoading(true) + } + + const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => { + if (!next) return + const normalized = { + totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0, + totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0 + } + setSnsStats(normalized) + hasSeededSnsStatsRef.current = true + setHasSeededSnsStats(true) + if (exportCacheScopeReadyRef.current) { + await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized) + } + } + try { - const result = await window.electronAPI.sns.getExportStats() - if (result.success && result.data) { - setSnsStats({ - totalPosts: result.data.totalPosts || 0, - totalFriends: result.data.totalFriends || 0 - }) + const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200) + if (fastResult?.success && fastResult.data) { + const fastStats = { + totalPosts: fastResult.data.totalPosts || 0, + totalFriends: fastResult.data.totalFriends || 0 + } + if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) { + await applyStats(fastStats) + } + } + + if (options?.full) { + const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000) + if (result?.success && result.data) { + await applyStats({ + totalPosts: result.data.totalPosts || 0, + totalFriends: result.data.totalFriends || 0 + }) + } } } catch (error) { console.error('加载朋友圈导出统计失败:', error) } finally { - setIsSnsStatsLoading(false) + if (!options?.silent) { + setIsSnsStatsLoading(false) + } } }, []) @@ -497,9 +587,7 @@ function ExportPage() { setIsSessionEnriching(false) loadingMessageCountsRef.current.clear() loadingMetricsRef.current.clear() - sessionMessageCountsRef.current = {} sessionMetricsRef.current = {} - setSessionMessageCounts({}) setSessionMetrics({}) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -530,6 +618,16 @@ function ExportPage() { if (isStale()) return setSessions(baseSessions) + setSessionMessageCounts(prev => { + const next: Record = {} + for (const session of baseSessions) { + const count = prev[session.username] + if (typeof count === 'number') { + next[session.username] = count + } + } + return next + }) setIsLoading(false) // 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。 @@ -602,8 +700,8 @@ function ExportPage() { // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { - void loadSnsStats() - }, 180) + void loadSnsStats({ full: true }) + }, 120) return () => window.clearTimeout(timer) }, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats]) @@ -666,41 +764,43 @@ function ExportPage() { session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username) ) if (pending.length === 0) return - - const updates: Record = {} for (const session of pending) { loadingMessageCountsRef.current.add(session.username) } try { - const batchSize = 220 + const batchSize = pending.length > 100 ? 48 : 28 for (let i = 0; i < pending.length; i += batchSize) { if (loadTokenAtStart !== sessionLoadTokenRef.current) return const chunk = pending.slice(i, i + batchSize) const ids = chunk.map(session => session.username) + const chunkUpdates: Record = {} try { - const result = await window.electronAPI.chat.getSessionMessageCounts(ids) + const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000) + if (!result) { + continue + } for (const session of chunk) { - const value = result.success && result.counts ? result.counts[session.username] : undefined - updates[session.username] = typeof value === 'number' ? value : 0 + const value = result?.success && result.counts ? result.counts[session.username] : undefined + chunkUpdates[session.username] = typeof value === 'number' ? value : 0 } } catch (error) { console.error('加载会话总消息数失败:', error) for (const session of chunk) { - updates[session.username] = 0 + chunkUpdates[session.username] = 0 } } + + if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) { + setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates })) + } } } finally { for (const session of pending) { loadingMessageCountsRef.current.delete(session.username) } } - - if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) { - setSessionMessageCounts(prev => ({ ...prev, ...updates })) - } }, []) const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => { @@ -787,35 +887,43 @@ function ExportPage() { useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH) cursor += MESSAGE_COUNT_BACKGROUND_BATCH void ensureSessionMessageCounts(chunk) }, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMessageCounts]) + }, [sessions, activeTab, ensureSessionMessageCounts]) useEffect(() => { if (sessions.length === 0) return + const prioritySessions = [ + ...sessions.filter(session => session.kind === activeTab), + ...sessions.filter(session => session.kind !== activeTab) + ] let cursor = 0 const timer = window.setInterval(() => { - if (cursor >= sessions.length) { + if (cursor >= prioritySessions.length) { window.clearInterval(timer) return } - const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) + const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH) cursor += METRICS_BACKGROUND_BATCH void ensureSessionMetrics(chunk) }, METRICS_BACKGROUND_INTERVAL_MS) return () => window.clearInterval(timer) - }, [sessions, ensureSessionMetrics]) + }, [sessions, activeTab, ensureSessionMetrics]) const selectedCount = selectedSessions.size @@ -1059,7 +1167,7 @@ function ExportPage() { const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts) setLastSnsExportPostCount(mergedExportedCount) await configService.setExportLastSnsPostCount(mergedExportedCount) - await loadSnsStats() + await loadSnsStats({ full: true }) updateTask(next.id, task => ({ ...task, @@ -1519,6 +1627,7 @@ function ExportPage() { const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0 const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource const isSessionCardStatsLoading = isLoading || isBaseConfigLoading + const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const showInitialSkeleton = isLoading && sessions.length === 0 @@ -1574,7 +1683,7 @@ function ExportPage() { {contentCards.map(card => { const Icon = card.icon const isCardStatsLoading = card.type === 'sns' - ? (isSnsStatsLoading || isBaseConfigLoading) + ? isSnsCardStatsLoading : isSessionCardStatsLoading return (
diff --git a/src/services/config.ts b/src/services/config.ts index 7927939..53969ef 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -36,6 +36,8 @@ export const CONFIG_KEYS = { EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap', EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap', EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount', + EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', + EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', // 安全 AUTH_ENABLED: 'authEnabled', @@ -449,6 +451,104 @@ export async function setExportLastSnsPostCount(count: number): Promise { await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized) } +export interface ExportSessionMessageCountCacheItem { + updatedAt: number + counts: Record +} + +export interface ExportSnsStatsCacheItem { + updatedAt: number + totalPosts: number + totalFriends: number +} + +export async function getExportSessionMessageCountCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const rawUpdatedAt = (rawItem as Record).updatedAt + const rawCounts = (rawItem as Record).counts + if (!rawCounts || typeof rawCounts !== 'object') return null + + const counts: Record = {} + for (const [sessionId, countRaw] of Object.entries(rawCounts as Record)) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + counts[sessionId] = Math.floor(countRaw) + } + } + + return { + updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0, + counts + } +} + +export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [sessionId, countRaw] of Object.entries(counts || {})) { + if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) { + normalized[sessionId] = Math.floor(countRaw) + } + } + + map[scopeKey] = { + updatedAt: Date.now(), + counts: normalized + } + await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map) +} + +export async function getExportSnsStatsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const raw = rawItem as Record + const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0 + ? Math.floor(raw.totalPosts) + : 0 + const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0 + ? Math.floor(raw.totalFriends) + : 0 + const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0 + + return { updatedAt, totalPosts, totalFriends } +} + +export async function setExportSnsStatsCache( + scopeKey: string, + stats: { totalPosts: number; totalFriends: number } +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + map[scopeKey] = { + updatedAt: Date.now(), + totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0, + totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0 + } + + await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) +} + // === 安全相关 === export async function getAuthEnabled(): Promise { diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 471ac70..e638331 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -554,6 +554,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>