From c5eed25f0620e6e231471ca0c0ab6c760873ec97 Mon Sep 17 00:00:00 2001 From: tisonhuang Date: Wed, 4 Mar 2026 13:14:40 +0800 Subject: [PATCH] feat(export): add sns arkmejson format and consolidate export flow changes --- electron/main.ts | 112 +--- electron/preload.ts | 10 +- electron/services/chatService.ts | 888 ++----------------------------- electron/services/snsService.ts | 329 +++++++++++- src/pages/ExportPage.tsx | 342 +++--------- src/pages/SnsPage.tsx | 11 +- src/types/electron.d.ts | 30 +- 7 files changed, 473 insertions(+), 1249 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 8264a49..4c97fb5 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -991,115 +991,11 @@ function registerIpcHandlers() { return chatService.getSessionMessageCounts(sessionIds) }) - ipcMain.handle('chat:getExportContentSessionCounts', async (_, options?: { - triggerRefresh?: boolean - forceRefresh?: boolean - traceId?: string + ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[], options?: { + skipDisplayName?: boolean + onlyMissingAvatar?: boolean }) => { - const traceId = typeof options?.traceId === 'string' ? options.traceId.trim() : '' - const startedAt = Date.now() - if (traceId) { - exportCardDiagnosticsService.stepStart({ - traceId, - stepId: 'main-ipc-export-content-counts', - stepName: 'Main IPC: chat:getExportContentSessionCounts', - source: 'main', - message: '主进程收到导出卡片统计请求', - data: { - forceRefresh: options?.forceRefresh === true, - triggerRefresh: options?.triggerRefresh !== false - } - }) - } - - try { - const result = await chatService.getExportContentSessionCounts(options) - if (traceId) { - exportCardDiagnosticsService.stepEnd({ - traceId, - stepId: 'main-ipc-export-content-counts', - stepName: 'Main IPC: chat:getExportContentSessionCounts', - source: 'main', - status: result?.success ? 'done' : 'failed', - durationMs: Date.now() - startedAt, - message: result?.success ? '主进程统计请求完成' : '主进程统计请求失败', - data: result?.success - ? { - totalSessions: result?.data?.totalSessions || 0, - pendingMediaSessions: result?.data?.pendingMediaSessions || 0, - refreshing: result?.data?.refreshing === true - } - : { error: result?.error || '未知错误' } - }) - } - return result - } catch (error) { - if (traceId) { - exportCardDiagnosticsService.stepEnd({ - traceId, - stepId: 'main-ipc-export-content-counts', - stepName: 'Main IPC: chat:getExportContentSessionCounts', - source: 'main', - status: 'failed', - durationMs: Date.now() - startedAt, - message: '主进程统计请求抛出异常', - data: { error: String(error) } - }) - } - throw error - } - }) - - ipcMain.handle('chat:refreshExportContentSessionCounts', async (_, options?: { - forceRefresh?: boolean - traceId?: string - }) => { - const traceId = typeof options?.traceId === 'string' ? options.traceId.trim() : '' - const startedAt = Date.now() - if (traceId) { - exportCardDiagnosticsService.stepStart({ - traceId, - stepId: 'main-ipc-refresh-export-content-counts', - stepName: 'Main IPC: chat:refreshExportContentSessionCounts', - source: 'main', - message: '主进程收到刷新导出卡片统计请求', - data: { forceRefresh: options?.forceRefresh === true } - }) - } - try { - const result = await chatService.refreshExportContentSessionCounts(options) - if (traceId) { - exportCardDiagnosticsService.stepEnd({ - traceId, - stepId: 'main-ipc-refresh-export-content-counts', - stepName: 'Main IPC: chat:refreshExportContentSessionCounts', - source: 'main', - status: result?.success ? 'done' : 'failed', - durationMs: Date.now() - startedAt, - message: result?.success ? '主进程刷新请求完成' : '主进程刷新请求失败', - data: result?.success ? undefined : { error: result?.error || '未知错误' } - }) - } - return result - } catch (error) { - if (traceId) { - exportCardDiagnosticsService.stepEnd({ - traceId, - stepId: 'main-ipc-refresh-export-content-counts', - stepName: 'Main IPC: chat:refreshExportContentSessionCounts', - source: 'main', - status: 'failed', - durationMs: Date.now() - startedAt, - message: '主进程刷新请求抛出异常', - data: { error: String(error) } - }) - } - throw error - } - }) - - ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => { - return chatService.enrichSessionsContactInfo(usernames) + return chatService.enrichSessionsContactInfo(usernames, options) }) ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => { diff --git a/electron/preload.ts b/electron/preload.ts index d531424..55526e3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -143,12 +143,10 @@ contextBridge.exposeInMainWorld('electronAPI', { getExportTabCounts: () => ipcRenderer.invoke('chat:getExportTabCounts'), getContactTypeCounts: () => ipcRenderer.invoke('chat:getContactTypeCounts'), getSessionMessageCounts: (sessionIds: string[]) => ipcRenderer.invoke('chat:getSessionMessageCounts', sessionIds), - getExportContentSessionCounts: (options?: { triggerRefresh?: boolean; forceRefresh?: boolean }) => - ipcRenderer.invoke('chat:getExportContentSessionCounts', options), - refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) => - ipcRenderer.invoke('chat:refreshExportContentSessionCounts', options), - enrichSessionsContactInfo: (usernames: string[]) => - ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames), + enrichSessionsContactInfo: ( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ) => ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames, options), getMessages: (sessionId: string, offset?: number, limit?: number, startTime?: number, endTime?: number, ascending?: boolean) => ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), getLatestMessages: (sessionId: string, limit?: number) => diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index b30952f..f9a7815 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -14,11 +14,6 @@ import { MessageCacheService } from './messageCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService' import { GroupMyMessageCountCacheService, GroupMyMessageCountCacheEntry } from './groupMyMessageCountCacheService' -import { - ExportContentScopeStatsEntry, - ExportContentSessionStatsEntry, - ExportContentStatsCacheService -} from './exportContentStatsCacheService' import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { voiceTranscribeService } from './voiceTranscribeService' import { LRUCache } from '../utils/LRUCache.js' @@ -173,18 +168,6 @@ interface ExportSessionStatsCacheMeta { source: 'memory' | 'disk' | 'fresh' } -interface ExportContentSessionCounts { - totalSessions: number - textSessions: number - voiceSessions: number - imageSessions: number - videoSessions: number - emojiSessions: number - pendingMediaSessions: number - updatedAt: number - refreshing: boolean -} - interface ExportTabCounts { private: number group: number @@ -229,7 +212,6 @@ class ChatService { private readonly messageCacheService: MessageCacheService private readonly sessionStatsCacheService: SessionStatsCacheService private readonly groupMyMessageCountCacheService: GroupMyMessageCountCacheService - private readonly exportContentStatsCacheService: ExportContentStatsCacheService private voiceWavCache: LRUCache private voiceTranscriptCache: LRUCache private voiceTranscriptPending = new Map>() @@ -270,24 +252,6 @@ class ChatService { private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000 private groupMyMessageCountCacheScope = '' private groupMyMessageCountMemoryCache = new Map() - private exportContentStatsScope = '' - private exportContentStatsMemory = new Map() - private exportContentStatsScopeUpdatedAt = 0 - private exportContentStatsRefreshPromise: Promise | null = null - private exportContentStatsRefreshQueued = false - private exportContentStatsRefreshForceQueued = false - private exportContentTextRefreshPromise: Promise | null = null - private exportContentTextRefreshQueued = false - private exportContentTextRefreshForceQueued = false - private exportContentSessionCountsInFlight: { - promise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> - forceRefresh: boolean - traceId: string - startedAt: number - } | null = null - private exportContentStatsDirtySessionIds = new Set() - private exportContentScopeSessionIdsCache: { ids: string[]; updatedAt: number } | null = null - private readonly exportContentScopeSessionIdsCacheTtlMs = 60 * 1000 constructor() { this.configService = new ConfigService() @@ -297,7 +261,6 @@ class ChatService { this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath()) this.groupMyMessageCountCacheService = new GroupMyMessageCountCacheService(this.configService.getCacheBasePath()) - this.exportContentStatsCacheService = new ExportContentStatsCacheService(this.configService.getCacheBasePath()) // 初始化LRU缓存,限制大小防止内存泄漏 this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries) this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录 @@ -368,10 +331,6 @@ class ChatService { // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) this.warmupMediaDbsCache() - // 预热导出内容会话统计缓存(后台异步,不阻塞连接) - void this.startExportContentStatsRefresh(false).catch((error) => { - console.error('ChatService: 后台刷新导出内容统计失败:', error) - }) return { success: true } } catch (e) { @@ -440,14 +399,6 @@ class ChatService { console.error('ChatService: 关闭数据库失败:', e) } this.connected = false - this.exportContentStatsRefreshPromise = null - this.exportContentStatsRefreshQueued = false - this.exportContentStatsRefreshForceQueued = false - this.exportContentTextRefreshPromise = null - this.exportContentTextRefreshQueued = false - this.exportContentTextRefreshForceQueued = false - this.exportContentSessionCountsInFlight = null - this.exportContentScopeSessionIdsCache = null } /** @@ -649,15 +600,27 @@ class ChatService { /** * 异步补充会话列表的联系人信息(公开方法,供前端调用) */ - async enrichSessionsContactInfo(usernames: string[]): Promise<{ + async enrichSessionsContactInfo( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ): Promise<{ success: boolean contacts?: Record error?: string }> { try { - if (usernames.length === 0) { + const normalizedUsernames = Array.from( + new Set( + (usernames || []) + .map((username) => String(username || '').trim()) + .filter(Boolean) + ) + ) + if (normalizedUsernames.length === 0) { return { success: true, contacts: {} } } + const skipDisplayName = options?.skipDisplayName === true + const onlyMissingAvatar = options?.onlyMissingAvatar === true const connectResult = await this.ensureConnected() if (!connectResult.success) { @@ -670,15 +633,23 @@ class ChatService { const updatedEntries: Record = {} // 检查缓存 - for (const username of usernames) { + for (const username of normalizedUsernames) { const cached = this.avatarCache.get(username) + const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) + const cachedAvatarUrl = isValidAvatar ? cached?.avatarUrl : undefined + if (onlyMissingAvatar && cachedAvatarUrl) { + result[username] = { + displayName: skipDisplayName ? undefined : cached?.displayName, + avatarUrl: cachedAvatarUrl + } + continue + } // 如果缓存有效且有头像,直接使用;如果没有头像,也需要重新尝试获取 // 额外检查:如果头像是无效的 hex 格式(以 ffd8 开头),也需要重新获取 - const isValidAvatar = this.isValidAvatarUrl(cached?.avatarUrl) if (cached && now - cached.updatedAt < this.avatarCacheTtlMs && isValidAvatar) { result[username] = { - displayName: cached.displayName, - avatarUrl: cached.avatarUrl + displayName: skipDisplayName ? undefined : cached.displayName, + avatarUrl: cachedAvatarUrl } } else { missing.push(username) @@ -687,16 +658,19 @@ class ChatService { // 批量查询缺失的联系人信息 if (missing.length > 0) { - const [displayNames, avatarUrls] = await Promise.all([ - wcdbService.getDisplayNames(missing), - wcdbService.getAvatarUrls(missing) - ]) + const displayNames = skipDisplayName + ? null + : await wcdbService.getDisplayNames(missing) + const avatarUrls = await wcdbService.getAvatarUrls(missing) // 收集没有头像 URL 的用户名 const missingAvatars: string[] = [] for (const username of missing) { - const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined + const previous = this.avatarCache.get(username) + const displayName = displayNames?.success && displayNames.map + ? displayNames.map[username] + : undefined let avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined // 如果没有头像 URL,记录下来稍后从 head_image.db 获取 @@ -705,11 +679,14 @@ class ChatService { } const cacheEntry: ContactCacheEntry = { - displayName: displayName || username, + displayName: displayName || previous?.displayName || username, avatarUrl, updatedAt: now } - result[username] = { displayName, avatarUrl } + result[username] = { + displayName: skipDisplayName ? undefined : (displayName || previous?.displayName), + avatarUrl + } // 更新缓存并记录持久化 this.avatarCache.set(username, cacheEntry) updatedEntries[username] = cacheEntry @@ -1770,7 +1747,6 @@ class ChatService { if (scope === this.sessionMessageCountCacheScope) { this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) - this.refreshExportContentStatsScope(scope) return } this.sessionMessageCountCacheScope = scope @@ -1781,7 +1757,6 @@ class ChatService { this.sessionStatusCache.clear() this.refreshSessionStatsCacheScope(scope) this.refreshGroupMyMessageCountCacheScope(scope) - this.refreshExportContentStatsScope(scope) } private refreshGroupMyMessageCountCacheScope(scope: string): void { @@ -1790,780 +1765,6 @@ class ChatService { this.groupMyMessageCountMemoryCache.clear() } - private refreshExportContentStatsScope(scope: string): void { - if (scope === this.exportContentStatsScope) return - this.exportContentStatsScope = scope - this.exportContentStatsMemory.clear() - this.exportContentStatsDirtySessionIds.clear() - this.exportContentScopeSessionIdsCache = null - const scopeEntry = this.exportContentStatsCacheService.getScope(scope) - if (scopeEntry) { - this.exportContentStatsScopeUpdatedAt = scopeEntry.updatedAt - for (const [sessionId, entry] of Object.entries(scopeEntry.sessions)) { - this.exportContentStatsMemory.set(sessionId, { ...entry }) - } - const snapshotIds = Object.keys(scopeEntry.sessions) - .map((sessionId) => String(sessionId || '').trim()) - .filter(Boolean) - .filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_')) - if (snapshotIds.length > 0) { - this.exportContentScopeSessionIdsCache = { - ids: snapshotIds, - updatedAt: Date.now() - } - } - } else { - this.exportContentStatsScopeUpdatedAt = 0 - } - } - - private persistExportContentStatsScope(validSessionIds?: Set): void { - if (!this.exportContentStatsScope) return - const sessions: Record = {} - for (const [sessionId, entry] of this.exportContentStatsMemory.entries()) { - if (validSessionIds && !validSessionIds.has(sessionId)) continue - sessions[sessionId] = { ...entry } - } - - const updatedAt = this.exportContentStatsScopeUpdatedAt || Date.now() - const scopeEntry: ExportContentScopeStatsEntry = { - updatedAt, - sessions - } - this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry) - } - - private async listExportContentScopeSessionIds(force = false, traceId?: string): Promise { - const stepStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-list-export-content-session-ids', - stepName: '列出导出内容会话范围', - message: '开始获取导出内容统计范围会话', - data: { force } - }) - let success = false - let loadedCount = 0 - let errorMessage = '' - - const now = Date.now() - if ( - !force && - this.exportContentScopeSessionIdsCache && - now - this.exportContentScopeSessionIdsCache.updatedAt <= this.exportContentScopeSessionIdsCacheTtlMs - ) { - success = true - loadedCount = this.exportContentScopeSessionIdsCache.ids.length - this.endExportDiagStep({ - traceId, - stepId: 'backend-list-export-content-session-ids', - stepName: '列出导出内容会话范围', - startedAt: stepStartedAt, - success, - message: '命中会话范围缓存', - data: { count: loadedCount, fromCache: true } - }) - return this.exportContentScopeSessionIdsCache.ids - } - - if (!force && this.exportContentStatsMemory.size > 0) { - const ids = Array.from(this.exportContentStatsMemory.keys()) - .map((sessionId) => String(sessionId || '').trim()) - .filter(Boolean) - .filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_')) - if (ids.length > 0) { - this.exportContentScopeSessionIdsCache = { - ids, - updatedAt: now - } - success = true - loadedCount = ids.length - this.endExportDiagStep({ - traceId, - stepId: 'backend-list-export-content-session-ids', - stepName: '列出导出内容会话范围', - startedAt: stepStartedAt, - success, - message: '命中持久化会话范围快照', - data: { count: loadedCount, fromCache: true, fromSnapshot: true } - }) - return ids - } - } - - try { - const sessionsResult = await this.getSessions() - if (!sessionsResult.success || !sessionsResult.sessions) { - errorMessage = sessionsResult.error || '获取会话失败' - return [] - } - - const ids = Array.from( - new Set( - sessionsResult.sessions - .map((session) => String(session.username || '').trim()) - .filter(Boolean) - .filter((sessionId) => sessionId.endsWith('@chatroom') || !sessionId.startsWith('gh_')) - ) - ) - - this.exportContentScopeSessionIdsCache = { - ids, - updatedAt: now - } - success = true - loadedCount = ids.length - return ids - } catch (error) { - errorMessage = String(error) - return [] - } finally { - this.endExportDiagStep({ - traceId, - stepId: 'backend-list-export-content-session-ids', - stepName: '列出导出内容会话范围', - startedAt: stepStartedAt, - success, - message: success ? '导出内容会话范围获取完成' : '导出内容会话范围获取失败', - data: success ? { count: loadedCount, fromCache: false } : { error: errorMessage || '未知错误' } - }) - } - } - - private createDefaultExportContentEntry(): ExportContentSessionStatsEntry { - return { - updatedAt: 0, - hasAny: false, - hasVoice: false, - hasImage: false, - hasVideo: false, - hasEmoji: false, - mediaReady: false - } - } - - private isExportContentEntryDirty(sessionId: string): boolean { - return this.exportContentStatsDirtySessionIds.has(sessionId) - } - - private buildExportContentSessionCountsSnapshot( - sessionIds: string[], - options?: { forceRefresh?: boolean } - ): { - counts: ExportContentSessionCounts - missingTextCountSessionIds: string[] - pendingMediaSessionSet: Set - } { - const forceRefresh = options?.forceRefresh === true - const sessionIdSet = new Set(sessionIds) - - for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { - if (!sessionIdSet.has(sessionId)) { - this.exportContentStatsMemory.delete(sessionId) - this.exportContentStatsDirtySessionIds.delete(sessionId) - } - } - - const missingTextCountSessionIds: string[] = [] - let textSessions = 0 - let voiceSessions = 0 - let imageSessions = 0 - let videoSessions = 0 - let emojiSessions = 0 - const pendingMediaSessionSet = new Set() - - for (const sessionId of sessionIds) { - const entry = this.exportContentStatsMemory.get(sessionId) - const hasKnownTextCount = Boolean(entry && entry.updatedAt > 0) - - if (entry?.hasAny) { - textSessions += 1 - } - - if (forceRefresh || !entry || this.isExportContentEntryDirty(sessionId) || !hasKnownTextCount) { - missingTextCountSessionIds.push(sessionId) - } - - const hasMediaSnapshot = Boolean(entry && entry.mediaReady) - if (hasMediaSnapshot && entry) { - if (entry.hasVoice) voiceSessions += 1 - if (entry.hasImage) imageSessions += 1 - if (entry.hasVideo) videoSessions += 1 - if (entry.hasEmoji) emojiSessions += 1 - } else { - pendingMediaSessionSet.add(sessionId) - } - - if (this.isExportContentEntryDirty(sessionId) && hasMediaSnapshot) { - pendingMediaSessionSet.add(sessionId) - } - } - - return { - counts: { - totalSessions: sessionIds.length, - textSessions, - voiceSessions, - imageSessions, - videoSessions, - emojiSessions, - pendingMediaSessions: pendingMediaSessionSet.size, - updatedAt: this.exportContentStatsScopeUpdatedAt, - refreshing: this.exportContentStatsRefreshPromise !== null || this.exportContentTextRefreshPromise !== null - }, - missingTextCountSessionIds, - pendingMediaSessionSet - } - } - - private async startExportContentTextCountsRefresh( - force = false, - traceId?: string, - sessionIdsHint?: string[] - ): Promise { - const refreshStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - message: '开始补全文本会话计数', - data: { force, hintSessions: Array.isArray(sessionIdsHint) ? sessionIdsHint.length : 0 } - }) - - if (this.exportContentTextRefreshPromise) { - this.exportContentTextRefreshQueued = true - this.exportContentTextRefreshForceQueued = this.exportContentTextRefreshForceQueued || force - this.endExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - startedAt: refreshStartedAt, - success: true, - message: '复用进行中的文本会话补全任务', - data: { queued: true, forceQueued: this.exportContentTextRefreshForceQueued } - }) - return this.exportContentTextRefreshPromise - } - - const task = (async () => { - const sessionIds = Array.isArray(sessionIdsHint) && sessionIdsHint.length > 0 - ? sessionIdsHint - : await this.listExportContentScopeSessionIds(force, traceId) - if (sessionIds.length === 0) return - - const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh: force }) - const targets = snapshot.missingTextCountSessionIds - if (targets.length === 0) return - - const stallStartedAt = Date.now() - const textCountStallTimer = setTimeout(() => { - this.logExportDiag({ - traceId, - source: 'backend', - level: 'warn', - message: '补全文本会话计数耗时较长', - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - status: 'running', - data: { - elapsedMs: Date.now() - stallStartedAt, - missingSessions: targets.length - } - }) - }, 3000) - - try { - const textCountResult = await this.getSessionMessageCounts(targets, { - preferHintCache: false, - bypassSessionCache: true, - traceId - }) - if (!textCountResult.success || !textCountResult.counts) { - throw new Error(textCountResult.error || '文本会话计数补全失败') - } - - const now = Date.now() - for (const sessionId of targets) { - const count = textCountResult.counts[sessionId] - const hasAny = Number.isFinite(count) && Number(count) > 0 - const prevEntry = this.exportContentStatsMemory.get(sessionId) || this.createDefaultExportContentEntry() - this.exportContentStatsMemory.set(sessionId, { - ...prevEntry, - hasAny, - updatedAt: now - }) - } - this.exportContentStatsScopeUpdatedAt = now - this.persistExportContentStatsScope(new Set(sessionIds)) - } finally { - clearTimeout(textCountStallTimer) - } - })() - - this.exportContentTextRefreshPromise = task - try { - await task - this.endExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - startedAt: refreshStartedAt, - success: true, - message: '文本会话计数补全完成' - }) - } catch (error) { - this.endExportDiagStep({ - traceId, - stepId: 'backend-fill-text-counts', - stepName: '补全文本会话计数', - startedAt: refreshStartedAt, - success: false, - message: '文本会话计数补全失败', - data: { error: String(error) } - }) - throw error - } finally { - this.exportContentTextRefreshPromise = null - if (this.exportContentTextRefreshQueued) { - const rerunForce = this.exportContentTextRefreshForceQueued - this.exportContentTextRefreshQueued = false - this.exportContentTextRefreshForceQueued = false - void this.startExportContentTextCountsRefresh(rerunForce, traceId).catch((error) => { - console.error('ChatService: 重新触发文本会话计数补全失败:', error) - }) - } - } - } - - private async collectExportContentEntry(sessionId: string, traceId?: string): Promise { - const stepStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-collect-export-content-entry', - stepName: '扫描单会话内容类型', - message: '开始扫描会话内容类型', - data: { sessionId } - }) - let fallback = false - const entry = this.createDefaultExportContentEntry() - const cursorResult = await wcdbService.openMessageCursorLite(sessionId, 400, false, 0, 0) - if (!cursorResult.success || !cursorResult.cursor) { - fallback = true - this.endExportDiagStep({ - traceId, - stepId: 'backend-collect-export-content-entry', - stepName: '扫描单会话内容类型', - startedAt: stepStartedAt, - success: false, - message: '会话内容扫描失败,游标未创建', - data: { sessionId, error: cursorResult.error || 'openMessageCursorLite failed' } - }) - return { - ...entry, - updatedAt: Date.now(), - mediaReady: false - } - } - - const cursor = cursorResult.cursor - try { - try { - let done = false - while (!done) { - const batch = await wcdbService.fetchMessageBatch(cursor) - if (!batch.success) { - break - } - const rows = Array.isArray(batch.rows) ? batch.rows as Record[] : [] - for (const row of rows) { - entry.hasAny = true - const localType = this.getRowInt( - row, - ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], - 1 - ) - if (localType === 34) entry.hasVoice = true - if (localType === 3) entry.hasImage = true - if (localType === 43) entry.hasVideo = true - if (localType === 47) entry.hasEmoji = true - - if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) { - done = true - break - } - } - - if (!batch.hasMore || rows.length === 0) { - break - } - } - } catch (error) { - this.endExportDiagStep({ - traceId, - stepId: 'backend-collect-export-content-entry', - stepName: '扫描单会话内容类型', - startedAt: stepStartedAt, - success: false, - message: '会话内容扫描异常', - data: { sessionId, error: String(error) } - }) - throw error - } - - entry.mediaReady = true - entry.updatedAt = Date.now() - if (!fallback) { - this.endExportDiagStep({ - traceId, - stepId: 'backend-collect-export-content-entry', - stepName: '扫描单会话内容类型', - startedAt: stepStartedAt, - success: true, - message: '会话内容扫描完成', - data: { - sessionId, - hasAny: entry.hasAny, - hasVoice: entry.hasVoice, - hasImage: entry.hasImage, - hasVideo: entry.hasVideo, - hasEmoji: entry.hasEmoji - } - }) - } - return entry - } finally { - await wcdbService.closeMessageCursor(cursor) - } - } - - private async startExportContentStatsRefresh(force = false, traceId?: string): Promise { - const refreshStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - message: '开始后台刷新导出内容会话统计', - data: { force } - }) - - if (this.exportContentStatsRefreshPromise) { - this.exportContentStatsRefreshQueued = true - this.exportContentStatsRefreshForceQueued = this.exportContentStatsRefreshForceQueued || force - this.logExportDiag({ - traceId, - level: 'debug', - source: 'backend', - message: '已有刷新任务在执行,已加入队列', - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - status: 'running', - data: { forceQueued: this.exportContentStatsRefreshForceQueued } - }) - this.endExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - startedAt: refreshStartedAt, - success: true, - message: '复用进行中的后台刷新任务', - data: { queued: true, forceQueued: this.exportContentStatsRefreshForceQueued } - }) - return this.exportContentStatsRefreshPromise - } - - const task = (async () => { - const sessionIds = await this.listExportContentScopeSessionIds(force, traceId) - const sessionIdSet = new Set(sessionIds) - const targets: string[] = [] - - for (const sessionId of sessionIds) { - const cached = this.exportContentStatsMemory.get(sessionId) - if (force || this.exportContentStatsDirtySessionIds.has(sessionId) || !cached || !cached.mediaReady) { - targets.push(sessionId) - } - } - - if (targets.length > 0) { - this.logExportDiag({ - traceId, - source: 'backend', - level: 'info', - message: '准备刷新导出内容会话统计目标', - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - status: 'running', - data: { - totalSessions: sessionIds.length, - targetSessions: targets.length - } - }) - await this.forEachWithConcurrency(targets, 3, async (sessionId) => { - try { - const nextEntry = await this.collectExportContentEntry(sessionId, traceId) - this.exportContentStatsMemory.set(sessionId, nextEntry) - if (nextEntry.mediaReady) { - this.exportContentStatsDirtySessionIds.delete(sessionId) - } else { - this.exportContentStatsDirtySessionIds.add(sessionId) - } - } catch (error) { - console.error('ChatService: 刷新导出内容会话统计失败:', sessionId, error) - this.exportContentStatsDirtySessionIds.add(sessionId) - } - }) - } - - for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { - if (!sessionIdSet.has(sessionId)) { - this.exportContentStatsMemory.delete(sessionId) - this.exportContentStatsDirtySessionIds.delete(sessionId) - } - } - - this.exportContentStatsScopeUpdatedAt = Date.now() - this.persistExportContentStatsScope(sessionIdSet) - })() - - this.exportContentStatsRefreshPromise = task - try { - await task - this.endExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - startedAt: refreshStartedAt, - success: true, - message: '后台刷新导出内容统计完成', - data: { - force - } - }) - } catch (error) { - this.endExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-stats', - stepName: '后台刷新导出内容统计', - startedAt: refreshStartedAt, - success: false, - message: '后台刷新导出内容统计失败', - data: { error: String(error) } - }) - throw error - } finally { - this.exportContentStatsRefreshPromise = null - if (this.exportContentStatsRefreshQueued) { - const rerunForce = this.exportContentStatsRefreshForceQueued - this.exportContentStatsRefreshQueued = false - this.exportContentStatsRefreshForceQueued = false - void this.startExportContentStatsRefresh(rerunForce, traceId).catch((error) => { - console.error('ChatService: 重新触发导出内容统计刷新失败:', error) - }) - } - } - } - - async getExportContentSessionCounts(options?: { - triggerRefresh?: boolean - forceRefresh?: boolean - traceId?: string - }): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> { - const traceId = this.normalizeExportDiagTraceId(options?.traceId) - const forceRefresh = options?.forceRefresh === true - const triggerRefresh = options?.triggerRefresh !== false - const stepStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-get-export-content-session-counts', - stepName: '获取导出卡片统计', - message: '开始计算导出卡片统计', - data: { - triggerRefresh, - forceRefresh - } - }) - let stepSuccess = false - let stepError = '' - let stepResult: ExportContentSessionCounts | undefined - let activePromise: Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> | null = null - let createdInFlight = false - - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - stepError = connectResult.error || '数据库未连接' - return { success: false, error: stepError } - } - this.refreshSessionMessageCountCacheScope() - - if (!forceRefresh && this.exportContentSessionCountsInFlight) { - const sessionIds = this.exportContentScopeSessionIdsCache?.ids || Array.from(this.exportContentStatsMemory.keys()) - const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh: false }) - if (triggerRefresh) { - if (snapshot.missingTextCountSessionIds.length > 0) { - void this.startExportContentTextCountsRefresh(false, traceId, sessionIds).catch((error) => { - console.error('ChatService: 触发文本会话计数补全失败:', error) - }) - } - if (snapshot.pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0) { - void this.startExportContentStatsRefresh(false, traceId).catch((error) => { - console.error('ChatService: 触发导出内容统计刷新失败:', error) - }) - } - } - this.logExportDiag({ - traceId, - source: 'backend', - level: 'info', - message: '返回缓存快照,后台统计任务仍在进行', - stepId: 'backend-get-export-content-session-counts', - stepName: '获取导出卡片统计', - status: 'running', - data: { - inFlightTraceId: this.exportContentSessionCountsInFlight.traceId, - inFlightElapsedMs: Date.now() - this.exportContentSessionCountsInFlight.startedAt, - totalSessions: snapshot.counts.totalSessions - } - }) - stepResult = { - ...snapshot.counts, - refreshing: true - } - stepSuccess = true - return { success: true, data: stepResult } - } - - if (this.exportContentSessionCountsInFlight) { - this.logExportDiag({ - traceId, - source: 'backend', - level: 'info', - message: '复用进行中的导出卡片统计任务', - stepId: 'backend-get-export-content-session-counts', - stepName: '获取导出卡片统计', - status: 'running', - data: { - inFlightTraceId: this.exportContentSessionCountsInFlight.traceId, - inFlightForceRefresh: this.exportContentSessionCountsInFlight.forceRefresh, - requestedForceRefresh: forceRefresh, - inFlightElapsedMs: Date.now() - this.exportContentSessionCountsInFlight.startedAt - } - }) - activePromise = this.exportContentSessionCountsInFlight.promise - } else { - const createdPromise = (async () => { - const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId) - const snapshot = this.buildExportContentSessionCountsSnapshot(sessionIds, { forceRefresh }) - - if (triggerRefresh) { - if (snapshot.missingTextCountSessionIds.length > 0) { - void this.startExportContentTextCountsRefresh(forceRefresh, traceId, sessionIds).catch((error) => { - console.error('ChatService: 触发文本会话计数补全失败:', error) - }) - } - if (forceRefresh || snapshot.pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0) { - void this.startExportContentStatsRefresh(forceRefresh, traceId).catch((error) => { - console.error('ChatService: 触发导出内容统计刷新失败:', error) - }) - } - } - - return { - success: true, - data: { - ...snapshot.counts, - refreshing: this.exportContentStatsRefreshPromise !== null || this.exportContentTextRefreshPromise !== null - } - } - })() - activePromise = createdPromise - this.exportContentSessionCountsInFlight = { - promise: createdPromise, - forceRefresh, - traceId, - startedAt: Date.now() - } - createdInFlight = true - } - - if (!activePromise) { - stepError = '统计任务未初始化' - return { success: false, error: stepError } - } - - const result = await activePromise - stepSuccess = result.success - if (result.success && result.data) { - stepResult = result.data - } else { - stepError = result.error || '未知错误' - } - return result - } catch (e) { - console.error('ChatService: 获取导出内容会话统计失败:', e) - stepError = String(e) - return { success: false, error: String(e) } - } finally { - if (createdInFlight && activePromise && this.exportContentSessionCountsInFlight?.promise === activePromise) { - this.exportContentSessionCountsInFlight = null - } - this.endExportDiagStep({ - traceId, - stepId: 'backend-get-export-content-session-counts', - stepName: '获取导出卡片统计', - startedAt: stepStartedAt, - success: stepSuccess, - message: stepSuccess ? '导出卡片统计计算完成' : '导出卡片统计计算失败', - data: stepSuccess - ? { - totalSessions: stepResult?.totalSessions || 0, - pendingMediaSessions: stepResult?.pendingMediaSessions || 0, - refreshing: stepResult?.refreshing === true - } - : { error: stepError || '未知错误' } - }) - } - } - - async refreshExportContentSessionCounts(options?: { forceRefresh?: boolean; traceId?: string }): Promise<{ success: boolean; error?: string }> { - const traceId = this.normalizeExportDiagTraceId(options?.traceId) - const stepStartedAt = this.startExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-session-counts', - stepName: '刷新导出卡片统计', - message: '开始刷新导出卡片统计', - data: { forceRefresh: options?.forceRefresh === true } - }) - let stepSuccess = false - let stepError = '' - - try { - const connectResult = await this.ensureConnected() - if (!connectResult.success) { - stepError = connectResult.error || '数据库未连接' - return { success: false, error: connectResult.error || '数据库未连接' } - } - this.refreshSessionMessageCountCacheScope() - const force = options?.forceRefresh === true - void this.startExportContentTextCountsRefresh(force, traceId).catch((error) => { - console.error('ChatService: 刷新导出卡片统计(文本计数)失败:', error) - }) - void this.startExportContentStatsRefresh(force, traceId).catch((error) => { - console.error('ChatService: 刷新导出卡片统计(内容类型)失败:', error) - }) - stepSuccess = true - return { success: true } - } catch (e) { - console.error('ChatService: 刷新导出内容会话统计失败:', e) - stepError = String(e) - return { success: false, error: String(e) } - } finally { - this.endExportDiagStep({ - traceId, - stepId: 'backend-refresh-export-content-session-counts', - stepName: '刷新导出卡片统计', - startedAt: stepStartedAt, - success: stepSuccess, - message: stepSuccess ? '刷新导出卡片统计完成' : '刷新导出卡片统计失败', - data: stepSuccess ? undefined : { error: stepError || '未知错误' } - }) - } - } - private refreshSessionStatsCacheScope(scope: string): void { if (scope === this.sessionStatsCacheScope) return this.sessionStatsCacheScope = scope @@ -2751,8 +1952,6 @@ class ChatService { if (ids.size > 0) { ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId)) - this.exportContentScopeSessionIdsCache = null - ids.forEach((sessionId) => this.exportContentStatsDirtySessionIds.add(sessionId)) if (Array.from(ids).some((id) => id.includes('@chatroom'))) { this.allGroupSessionIdsCache = null } @@ -2768,10 +1967,6 @@ class ChatService { normalizedType.includes('contact') ) { this.clearSessionStatsCacheForScope() - this.exportContentScopeSessionIdsCache = null - for (const sessionId of this.exportContentStatsMemory.keys()) { - this.exportContentStatsDirtySessionIds.add(sessionId) - } } } @@ -4939,11 +4134,6 @@ class ChatService { this.sessionStatsCacheService.clearAll() this.groupMyMessageCountMemoryCache.clear() this.groupMyMessageCountCacheService.clearAll() - this.exportContentStatsMemory.clear() - this.exportContentStatsDirtySessionIds.clear() - this.exportContentScopeSessionIdsCache = null - this.exportContentStatsScopeUpdatedAt = 0 - this.exportContentStatsCacheService.clearAll() } for (const state of this.hardlinkCache.values()) { diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 10c65f8..33bf11e 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -44,6 +44,68 @@ export interface SnsPost { linkUrl?: string } +interface SnsContactIdentity { + username: string + wxid: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string +} + +interface ParsedLikeUser { + username?: string + nickname?: string +} + +interface ParsedCommentItem { + id: string + nickname: string + username?: string + content: string + refCommentId: string + refUsername?: string + refNickname?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] +} + +interface ArkmeLikeDetail { + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + source: 'xml' | 'legacy' +} + +interface ArkmeCommentDetail { + id: string + nickname: string + username?: string + wxid?: string + alias?: string + wechatId?: string + remark?: string + nickName?: string + displayName: string + content: string + refCommentId: string + refNickname?: string + refUsername?: string + refWxid?: string + refAlias?: string + refWechatId?: string + refRemark?: string + refNickName?: string + refDisplayName?: string + emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] + source: 'xml' | 'legacy' +} + const fixSnsUrl = (url: string, token?: string, isVideo: boolean = false) => { @@ -127,7 +189,7 @@ const extractVideoKey = (xml: string): string | undefined => { /** * 从 XML 中解析评论信息(含表情包、回复关系) */ -function parseCommentsFromXml(xml: string): { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string; emojis?: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }[] }[] { +function parseCommentsFromXml(xml: string): ParsedCommentItem[] { if (!xml) return [] type CommentItem = { @@ -239,6 +301,204 @@ class SnsService { this.contactCache = new ContactCacheService(this.configService.get('cachePath') as string) } + private toOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : undefined + } + + private async resolveContactIdentity( + username: string, + identityCache: Map> + ): Promise { + const normalized = String(username || '').trim() + if (!normalized) return null + + let pending = identityCache.get(normalized) + if (!pending) { + pending = (async () => { + const cached = this.contactCache.get(normalized) + let alias: string | undefined + let remark: string | undefined + let nickName: string | undefined + + try { + const contactResult = await wcdbService.getContact(normalized) + if (contactResult.success && contactResult.contact) { + const contact = contactResult.contact + alias = this.toOptionalString(contact.alias ?? contact.Alias) + remark = this.toOptionalString(contact.remark ?? contact.Remark) + nickName = this.toOptionalString(contact.nickName ?? contact.nick_name ?? contact.nickname ?? contact.NickName) + } + } catch { + // 联系人补全失败不影响导出 + } + + const displayName = remark || nickName || alias || cached?.displayName || normalized + return { + username: normalized, + wxid: normalized, + alias, + wechatId: alias, + remark, + nickName, + displayName + } + })() + identityCache.set(normalized, pending) + } + + return pending + } + + private parseLikeUsersFromXml(xml: string): ParsedLikeUser[] { + if (!xml) return [] + const likes: ParsedLikeUser[] = [] + try { + let likeListMatch = xml.match(/([\s\S]*?)<\/LikeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeUserList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/likeList>/i) + if (!likeListMatch) likeListMatch = xml.match(/([\s\S]*?)<\/like_user_list>/i) + if (!likeListMatch) return likes + + const likeUserRegex = /<(?:LikeUser|likeUser|user_comment)>([\s\S]*?)<\/(?:LikeUser|likeUser|user_comment)>/gi + let m: RegExpExecArray | null + while ((m = likeUserRegex.exec(likeListMatch[1])) !== null) { + const block = m[1] + const username = this.toOptionalString(block.match(/([^<]*)<\/username>/i)?.[1]) + const nickname = this.toOptionalString( + block.match(/([^<]*)<\/nickname>/i)?.[1] + || block.match(/([^<]*)<\/nickName>/i)?.[1] + ) + if (username || nickname) { + likes.push({ username, nickname }) + } + } + } catch (e) { + console.error('[SnsService] 解析点赞用户失败:', e) + } + return likes + } + + private async buildArkmeInteractionDetails( + post: SnsPost, + identityCache: Map> + ): Promise<{ likesDetail: ArkmeLikeDetail[]; commentsDetail: ArkmeCommentDetail[] }> { + const xmlLikes = this.parseLikeUsersFromXml(post.rawXml || '') + const likeCandidates: ParsedLikeUser[] = xmlLikes.length > 0 + ? xmlLikes + : (post.likes || []).map((nickname) => ({ nickname })) + const likeSource: 'xml' | 'legacy' = xmlLikes.length > 0 ? 'xml' : 'legacy' + const likesDetail: ArkmeLikeDetail[] = [] + const likeSeen = new Set() + + for (const like of likeCandidates) { + const identity = like.username + ? await this.resolveContactIdentity(like.username, identityCache) + : null + const nickname = like.nickname || identity?.displayName || like.username || '' + const username = identity?.username || like.username + const key = `${username || ''}|${nickname}` + if (likeSeen.has(key)) continue + likeSeen.add(key) + likesDetail.push({ + nickname, + username, + wxid: username, + alias: identity?.alias, + wechatId: identity?.wechatId, + remark: identity?.remark, + nickName: identity?.nickName, + displayName: identity?.displayName || nickname || username || '', + source: likeSource + }) + } + + const xmlComments = parseCommentsFromXml(post.rawXml || '') + const commentMap = new Map() + for (const comment of post.comments || []) { + if (comment.id) commentMap.set(comment.id, comment) + } + + const commentsBase: ParsedCommentItem[] = xmlComments.length > 0 + ? xmlComments.map((comment) => { + const fallback = comment.id ? commentMap.get(comment.id) : undefined + return { + id: comment.id || fallback?.id || '', + nickname: comment.nickname || fallback?.nickname || '', + username: comment.username, + content: comment.content || fallback?.content || '', + refCommentId: comment.refCommentId || fallback?.refCommentId || '', + refUsername: comment.refUsername, + refNickname: comment.refNickname || fallback?.refNickname, + emojis: comment.emojis && comment.emojis.length > 0 ? comment.emojis : fallback?.emojis + } + }) + : (post.comments || []).map((comment) => ({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + })) + + if (xmlComments.length > 0) { + const mappedIds = new Set(commentsBase.map((comment) => comment.id).filter(Boolean)) + for (const comment of post.comments || []) { + if (comment.id && mappedIds.has(comment.id)) continue + commentsBase.push({ + id: comment.id || '', + nickname: comment.nickname || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname, + emojis: comment.emojis + }) + } + } + + const commentSource: 'xml' | 'legacy' = xmlComments.length > 0 ? 'xml' : 'legacy' + const commentsDetail: ArkmeCommentDetail[] = [] + + for (const comment of commentsBase) { + const actor = comment.username + ? await this.resolveContactIdentity(comment.username, identityCache) + : null + const refActor = comment.refUsername + ? await this.resolveContactIdentity(comment.refUsername, identityCache) + : null + const nickname = comment.nickname || actor?.displayName || comment.username || '' + const username = actor?.username || comment.username + const refUsername = refActor?.username || comment.refUsername + commentsDetail.push({ + id: comment.id || '', + nickname, + username, + wxid: username, + alias: actor?.alias, + wechatId: actor?.wechatId, + remark: actor?.remark, + nickName: actor?.nickName, + displayName: actor?.displayName || nickname || username || '', + content: comment.content || '', + refCommentId: comment.refCommentId || '', + refNickname: comment.refNickname || refActor?.displayName, + refUsername, + refWxid: refUsername, + refAlias: refActor?.alias, + refWechatId: refActor?.wechatId, + refRemark: refActor?.remark, + refNickName: refActor?.nickName, + refDisplayName: refActor?.displayName, + emojis: comment.emojis, + source: commentSource + }) + } + + return { likesDetail, commentsDetail } + } + private parseCountValue(row: any): number { if (!row || typeof row !== 'object') return 0 const raw = row.total ?? row.count ?? row.cnt ?? Object.values(row)[0] @@ -821,7 +1081,7 @@ class SnsService { */ async exportTimeline(options: { outputDir: string - format: 'json' | 'html' + format: 'json' | 'html' | 'arkmejson' usernames?: string[] keyword?: string exportMedia?: boolean @@ -1026,6 +1286,71 @@ class SnsService { })) } await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') + } else if (format === 'arkmejson') { + outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`) + progressCallback?.({ current: 0, total: allPosts.length, status: '正在构建 ArkmeJSON 数据...' }) + + const identityCache = new Map>() + const posts: any[] = [] + let built = 0 + + for (const post of allPosts) { + const controlState = getControlState() + if (controlState) { + return buildInterruptedResult(controlState, allPosts.length, mediaCount) + } + + const authorIdentity = await this.resolveContactIdentity(post.username, identityCache) + const { likesDetail, commentsDetail } = await this.buildArkmeInteractionDetails(post, identityCache) + + posts.push({ + id: post.id, + username: post.username, + nickname: post.nickname, + author: authorIdentity + ? { + ...authorIdentity + } + : { + username: post.username, + wxid: post.username, + displayName: post.nickname || post.username + }, + createTime: post.createTime, + createTimeStr: new Date(post.createTime * 1000).toLocaleString('zh-CN'), + contentDesc: post.contentDesc, + type: post.type, + media: post.media.map(m => ({ + url: m.url, + thumb: m.thumb, + localPath: (m as any).localPath || undefined + })), + likes: post.likes, + comments: post.comments, + likesDetail, + commentsDetail, + linkTitle: (post as any).linkTitle, + linkUrl: (post as any).linkUrl + }) + + built++ + if (built % 20 === 0 || built === allPosts.length) { + progressCallback?.({ current: built, total: allPosts.length, status: `正在构建 ArkmeJSON 数据 (${built}/${allPosts.length})...` }) + } + } + + const exportData = { + exportTime: new Date().toISOString(), + format: 'arkmejson', + schemaVersion: '1.0.0', + totalPosts: allPosts.length, + filters: { + usernames: usernames || [], + keyword: keyword || '' + }, + posts + } + await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8') } else { // HTML 格式 outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 68da709..3e1e291 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -51,6 +51,7 @@ type SessionLayout = 'shared' | 'per-session' type DisplayNamePreference = 'group-nickname' | 'remark' | 'nickname' type TextExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' +type SnsTimelineExportFormat = 'json' | 'html' | 'arkmejson' interface ExportOptions { format: TextExportFormat @@ -110,7 +111,7 @@ interface ExportTaskPayload { contentType?: ContentType sessionNames: string[] snsOptions?: { - format: 'json' | 'html' + format: SnsTimelineExportFormat exportMedia?: boolean startTime?: number endTime?: number @@ -466,7 +467,6 @@ const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString( const createExportDiagTraceId = (): string => `export-card-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` const CONTACT_ENRICH_TIMEOUT_MS = 7000 const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000 -const EXPORT_AVATAR_RECHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 @@ -541,18 +541,6 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } -interface ExportContentSessionCountsSummary { - totalSessions: number - textSessions: number - voiceSessions: number - imageSessions: number - videoSessions: number - emojiSessions: number - pendingMediaSessions: number - updatedAt: number - refreshing: boolean -} - type ExportCardDiagFilter = 'all' | 'frontend' | 'main' | 'backend' | 'worker' | 'warn' | 'error' type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' @@ -598,18 +586,6 @@ interface ExportCardDiagSnapshotState { } } -const defaultContentSessionCounts: ExportContentSessionCountsSummary = { - totalSessions: 0, - textSessions: 0, - voiceSessions: 0, - imageSessions: 0, - videoSessions: 0, - emojiSessions: 0, - pendingMediaSessions: 0, - updatedAt: 0, - refreshing: false -} - const defaultExportCardDiagSnapshot: ExportCardDiagSnapshotState = { logs: [], activeSteps: [], @@ -888,7 +864,8 @@ function ExportPage() { const [contactsAvatarEnrichProgress, setContactsAvatarEnrichProgress] = useState({ loaded: 0, total: 0, - running: false + running: false, + tab: null as ConversationTab | null }) const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) @@ -900,6 +877,7 @@ function ExportPage() { const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('A') + const [snsExportFormat, setSnsExportFormat] = useState('html') const [options, setOptions] = useState({ format: 'arkme-json', @@ -937,8 +915,6 @@ function ExportPage() { totalPosts: 0, totalFriends: 0 }) - const [contentSessionCounts, setContentSessionCounts] = useState(defaultContentSessionCounts) - const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false) const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false) const [showCardDiagnostics, setShowCardDiagnostics] = useState(false) const [diagFilter, setDiagFilter] = useState('all') @@ -960,6 +936,8 @@ function ExportPage() { const preselectAppliedRef = useRef(false) const exportCacheScopeRef = useRef('default') const exportCacheScopeReadyRef = useRef(false) + const activeTabRef = useRef('private') + const contactsDataRef = useRef([]) const contactsLoadVersionRef = useRef(0) const contactsLoadAttemptRef = useRef(0) const contactsLoadTimeoutTimerRef = useRef(null) @@ -970,9 +948,6 @@ function ExportPage() { const inProgressSessionIdsRef = useRef([]) const activeTaskCountRef = useRef(0) const hasBaseConfigReadyRef = useRef(false) - const contentSessionCountsForceRetryRef = useRef(0) - const contentSessionCountsInFlightRef = useRef | null>(null) - const contentSessionCountsInFlightTraceRef = useRef('') const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { setFrontendDiagLogs(prev => { @@ -1079,6 +1054,14 @@ function ExportPage() { contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs }, [contactsLoadTimeoutMs]) + useEffect(() => { + activeTabRef.current = activeTab + }, [activeTab]) + + useEffect(() => { + contactsDataRef.current = contactsList + }, [contactsList]) + const applyEnrichedContactsToList = useCallback((enrichedMap: Record) => { if (!enrichedMap || Object.keys(enrichedMap).length === 0) return setContactsList(prev => { @@ -1105,7 +1088,8 @@ function ExportPage() { const enrichContactsListInBackground = useCallback(async ( sourceContacts: ContactInfo[], loadVersion: number, - scopeKey: string + scopeKey: string, + targetTab: ConversationTab ) => { const sourceByUsername = new Map() for (const contact of sourceContacts) { @@ -1113,29 +1097,21 @@ function ExportPage() { sourceByUsername.set(contact.username, contact) } - const now = Date.now() - const usernames = sourceContacts + const usernames = Array.from(new Set(sourceContacts + .filter(contact => matchesContactTab(contact, targetTab)) .map(contact => contact.username) .filter(Boolean) .filter((username) => { const currentContact = sourceByUsername.get(username) - if (!currentContact) return false - const cacheEntry = contactsAvatarCacheRef.current[username] - if (!cacheEntry || !cacheEntry.avatarUrl) { - return !currentContact.avatarUrl - } - if (currentContact.avatarUrl && currentContact.avatarUrl !== cacheEntry.avatarUrl) { - return true - } - const checkedAt = cacheEntry.checkedAt || 0 - return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS - }) + return Boolean(currentContact && !currentContact.avatarUrl) + }))) const total = usernames.length setContactsAvatarEnrichProgress({ loaded: 0, total, - running: total > 0 + running: total > 0, + tab: targetTab }) if (total === 0) return @@ -1145,9 +1121,15 @@ function ExportPage() { if (batch.length === 0) continue try { - const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(batch) + const avatarResult = await withTimeout( + window.electronAPI.chat.enrichSessionsContactInfo(batch, { + skipDisplayName: true, + onlyMissingAvatar: true + }), + CONTACT_ENRICH_TIMEOUT_MS + ) if (contactsLoadVersionRef.current !== loadVersion) return - if (avatarResult.success && avatarResult.contacts) { + if (avatarResult?.success && avatarResult.contacts) { applyEnrichedContactsToList(avatarResult.contacts) for (const [username, enriched] of Object.entries(avatarResult.contacts)) { const prev = sourceByUsername.get(username) @@ -1180,7 +1162,8 @@ function ExportPage() { setContactsAvatarEnrichProgress({ loaded, total, - running: loaded < total + running: loaded < total, + tab: targetTab }) await new Promise(resolve => setTimeout(resolve, 0)) } @@ -1192,6 +1175,7 @@ function ExportPage() { const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { const scopeKey = options?.scopeKey || await ensureExportCacheScope() + const targetTab = activeTabRef.current const loadVersion = contactsLoadVersionRef.current + 1 contactsLoadVersionRef.current = loadVersion contactsLoadAttemptRef.current += 1 @@ -1228,7 +1212,8 @@ function ExportPage() { setContactsAvatarEnrichProgress({ loaded: 0, total: 0, - running: false + running: false, + tab: null }) try { @@ -1276,7 +1261,7 @@ function ExportPage() { ).catch((error) => { console.error('写入导出页通讯录缓存失败:', error) }) - void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey) + void enrichContactsListInBackground(contactsWithAvatarCache, loadVersion, scopeKey, targetTab) return } @@ -1351,13 +1336,28 @@ function ExportPage() { } }, [isExportRoute, ensureExportCacheScope, loadContactsList, syncContactTypeCounts]) + useEffect(() => { + if (!isExportRoute || isContactsListLoading || contactsDataRef.current.length === 0) return + let cancelled = false + const loadVersion = contactsLoadVersionRef.current + void (async () => { + const scopeKey = await ensureExportCacheScope() + if (cancelled || contactsLoadVersionRef.current !== loadVersion) return + await enrichContactsListInBackground(contactsDataRef.current, loadVersion, scopeKey, activeTab) + })() + return () => { + cancelled = true + } + }, [activeTab, ensureExportCacheScope, enrichContactsListInBackground, isContactsListLoading, isExportRoute]) + useEffect(() => { if (isExportRoute) return contactsLoadVersionRef.current += 1 setContactsAvatarEnrichProgress({ loaded: 0, total: 0, - running: false + running: false, + tab: null }) }, [isExportRoute]) @@ -1538,163 +1538,6 @@ function ExportPage() { } }, []) - const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { - if (contentSessionCountsInFlightRef.current) { - logFrontendDiag({ - level: 'info', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'running', - message: '统计请求仍在进行中,跳过本次轮询', - data: { - silent: options?.silent === true, - forceRefresh: options?.forceRefresh === true, - inFlightTraceId: contentSessionCountsInFlightTraceRef.current || undefined - } - }) - return - } - - const traceId = createExportDiagTraceId() - const startedAt = Date.now() - const task = (async () => { - logFrontendDiag({ - traceId, - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'running', - message: '开始请求导出卡片统计', - data: { - silent: options?.silent === true, - forceRefresh: options?.forceRefresh === true - } - }) - try { - const result = await withTimeout( - window.electronAPI.chat.getExportContentSessionCounts({ - triggerRefresh: true, - forceRefresh: options?.forceRefresh === true, - traceId - }), - 3200 - ) - if (!result) { - logFrontendDiag({ - traceId, - level: 'warn', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'timeout', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求超时(3200ms,后台可能仍在处理)' - }) - return - } - if (result?.success && result.data) { - const next: ExportContentSessionCountsSummary = { - totalSessions: Number.isFinite(result.data.totalSessions) ? Math.max(0, Math.floor(result.data.totalSessions)) : 0, - textSessions: Number.isFinite(result.data.textSessions) ? Math.max(0, Math.floor(result.data.textSessions)) : 0, - voiceSessions: Number.isFinite(result.data.voiceSessions) ? Math.max(0, Math.floor(result.data.voiceSessions)) : 0, - imageSessions: Number.isFinite(result.data.imageSessions) ? Math.max(0, Math.floor(result.data.imageSessions)) : 0, - videoSessions: Number.isFinite(result.data.videoSessions) ? Math.max(0, Math.floor(result.data.videoSessions)) : 0, - emojiSessions: Number.isFinite(result.data.emojiSessions) ? Math.max(0, Math.floor(result.data.emojiSessions)) : 0, - pendingMediaSessions: Number.isFinite(result.data.pendingMediaSessions) ? Math.max(0, Math.floor(result.data.pendingMediaSessions)) : 0, - updatedAt: Number.isFinite(result.data.updatedAt) ? Math.max(0, Math.floor(result.data.updatedAt)) : 0, - refreshing: result.data.refreshing === true - } - setContentSessionCounts(next) - const looksLikeAllZero = next.totalSessions > 0 && - next.textSessions === 0 && - next.voiceSessions === 0 && - next.imageSessions === 0 && - next.videoSessions === 0 && - next.emojiSessions === 0 - - if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { - contentSessionCountsForceRetryRef.current += 1 - const refreshTraceId = createExportDiagTraceId() - logFrontendDiag({ - traceId: refreshTraceId, - stepId: 'frontend-force-refresh-content-session-counts', - stepName: '前端触发强制刷新导出卡片统计', - status: 'running', - message: '检测到统计全0,触发强制刷新' - }) - void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true, traceId: refreshTraceId }).then((refreshResult) => { - logFrontendDiag({ - traceId: refreshTraceId, - stepId: 'frontend-force-refresh-content-session-counts', - stepName: '前端触发强制刷新导出卡片统计', - status: refreshResult?.success ? 'done' : 'failed', - level: refreshResult?.success ? 'info' : 'warn', - message: refreshResult?.success ? '强制刷新请求已提交' : `强制刷新失败:${refreshResult?.error || '未知错误'}` - }) - }).catch((error) => { - logFrontendDiag({ - traceId: refreshTraceId, - stepId: 'frontend-force-refresh-content-session-counts', - stepName: '前端触发强制刷新导出卡片统计', - status: 'failed', - level: 'error', - message: '强制刷新请求异常', - data: { error: String(error) } - }) - }) - } else { - contentSessionCountsForceRetryRef.current = 0 - setHasSeededContentSessionCounts(true) - } - logFrontendDiag({ - traceId, - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'done', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求完成', - data: { - totalSessions: next.totalSessions, - pendingMediaSessions: next.pendingMediaSessions, - refreshing: next.refreshing - } - }) - } else { - logFrontendDiag({ - traceId, - level: 'warn', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'failed', - durationMs: Date.now() - startedAt, - message: `导出卡片统计请求失败:${result?.error || '未知错误'}` - }) - } - } catch (error) { - console.error('加载导出内容会话统计失败:', error) - logFrontendDiag({ - traceId, - level: 'error', - stepId: 'frontend-load-content-session-counts', - stepName: '前端请求导出卡片统计', - status: 'failed', - durationMs: Date.now() - startedAt, - message: '导出卡片统计请求异常', - data: { error: String(error) } - }) - } - })() - - contentSessionCountsInFlightRef.current = task - contentSessionCountsInFlightTraceRef.current = traceId - try { - await task - } finally { - if (contentSessionCountsInFlightRef.current === task) { - contentSessionCountsInFlightRef.current = null - contentSessionCountsInFlightTraceRef.current = '' - } - } - }, [logFrontendDiag]) - const loadSessions = useCallback(async () => { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken @@ -1795,7 +1638,6 @@ function ExportPage() { if (!contact?.username) continue sourceByUsername.set(contact.username, contact) } - const now = Date.now() const rawSessionMap = rawSessions.reduce>((map, session) => { map[session.username] = session return map @@ -1807,17 +1649,9 @@ function ExportPage() { .filter(Boolean) .filter((username) => { const currentContact = sourceByUsername.get(username) - const cacheEntry = avatarEntries[username] const session = rawSessionMap[username] const currentAvatarUrl = currentContact?.avatarUrl || session?.avatarUrl - if (!cacheEntry || !cacheEntry.avatarUrl) { - return !currentAvatarUrl - } - if (currentAvatarUrl && currentAvatarUrl !== cacheEntry.avatarUrl) { - return true - } - const checkedAt = cacheEntry.checkedAt || 0 - return now - checkedAt >= EXPORT_AVATAR_RECHECK_INTERVAL_MS + return !currentAvatarUrl }) let extraContactMap: Record = {} @@ -1828,7 +1662,10 @@ function ExportPage() { if (batch.length === 0) continue try { const enrichResult = await withTimeout( - window.electronAPI.chat.enrichSessionsContactInfo(batch), + window.electronAPI.chat.enrichSessionsContactInfo(batch, { + skipDisplayName: true, + onlyMissingAvatar: true + }), CONTACT_ENRICH_TIMEOUT_MS ) if (isStale()) return @@ -1941,7 +1778,6 @@ function ExportPage() { void loadBaseConfig() void ensureSharedTabCountsLoaded() void loadSessions() - void loadContentSessionCounts() // 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。 const timer = window.setTimeout(() => { @@ -1949,15 +1785,7 @@ function ExportPage() { }, 120) return () => window.clearTimeout(timer) - }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats, loadContentSessionCounts]) - - useEffect(() => { - if (!isExportRoute) return - const timer = window.setInterval(() => { - void loadContentSessionCounts({ silent: true }) - }, 3000) - return () => window.clearInterval(timer) - }, [isExportRoute, loadContentSessionCounts]) + }, [isExportRoute, ensureSharedTabCountsLoaded, loadBaseConfig, loadSessions, loadSnsStats]) useEffect(() => { if (!isExportRoute || !showCardDiagnostics) return @@ -2066,7 +1894,6 @@ function ExportPage() { } if (payload.scope === 'sns') { - next.format = prev.format === 'json' || prev.format === 'html' ? prev.format : 'html' return next } @@ -2210,7 +2037,7 @@ function ExportPage() { } const buildSnsExportOptions = () => { - const format: 'json' | 'html' = options.format === 'json' ? 'json' : 'html' + const format: SnsTimelineExportFormat = snsExportFormat const exportMediaEnabled = Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis) const dateRange = options.useAllTime ? null @@ -2332,7 +2159,7 @@ function ExportPage() { try { if (next.payload.scope === 'sns') { - const snsOptions = next.payload.snsOptions || { format: 'html' as const, exportMedia: false } + const snsOptions = next.payload.snsOptions || { format: 'html' as SnsTimelineExportFormat, exportMedia: false } const result = await window.electronAPI.sns.exportTimeline({ outputDir: next.payload.outputDir, format: snsOptions.format, @@ -2830,13 +2657,6 @@ function ExportPage() { const contentCards = useMemo(() => { const scopeSessions = sessions.filter(isContentScopeSession) const snsExportedCount = Math.min(lastSnsExportPostCount, snsStats.totalPosts) - const contentSessionCountByType: Record = { - text: contentSessionCounts.textSessions, - voice: contentSessionCounts.voiceSessions, - image: contentSessionCounts.imageSessions, - video: contentSessionCounts.videoSessions, - emoji: contentSessionCounts.emojiSessions - } const sessionCards = [ { type: 'text' as ContentType, icon: MessageSquareText }, @@ -2856,7 +2676,6 @@ function ExportPage() { ...item, label: contentTypeLabels[item.type], stats: [ - { label: '可导出会话数', value: contentSessionCountByType[item.type] || 0 }, { label: '已导出', value: exported } ] } @@ -2873,7 +2692,7 @@ function ExportPage() { } return [...sessionCards, snsCard] - }, [sessions, contentSessionCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) + }, [sessions, lastExportByContent, snsStats, lastSnsExportPostCount]) const mergedCardDiagLogs = useMemo(() => { const merged = [...backendDiagSnapshot.logs, ...frontendDiagLogs] @@ -3376,6 +3195,7 @@ function ExportPage() { contact.avatarUrl ? count + 1 : count ), 0) }, [contactsList]) + const isCurrentTabAvatarEnrichRunning = contactsAvatarEnrichProgress.running && contactsAvatarEnrichProgress.tab === activeTab useEffect(() => { if (!contactsListRef.current) return @@ -3598,16 +3418,19 @@ function ExportPage() { const scopeCountLabel = exportDialog.scope === 'sns' ? `共 ${snsStats.totalPosts} 条朋友圈动态` : `共 ${exportDialog.sessionIds.length} 个会话` + const snsFormatOptions: Array<{ value: SnsTimelineExportFormat; label: string; desc: string }> = [ + { value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' }, + { value: 'json', label: 'JSON', desc: '原始结构化格式(兼容旧导入)' }, + { value: 'arkmejson', label: 'ArkmeJSON', desc: '增强结构化格式,包含互动身份字段' } + ] const formatCandidateOptions = exportDialog.scope === 'sns' - ? formatOptions.filter(option => option.value === 'html' || option.value === 'json') + ? snsFormatOptions : formatOptions const isContentScopeDialog = exportDialog.scope === 'content' const isContentTextDialog = isContentScopeDialog && exportDialog.contentType === 'text' const shouldShowFormatSection = !isContentScopeDialog || isContentTextDialog const shouldShowMediaSection = !isContentScopeDialog const isTabCountComputing = isSharedTabCountsLoading && !isSharedTabCountsReady - const isSessionCardStatsLoading = isBaseConfigLoading || !hasSeededContentSessionCounts - const isSessionCardStatsRefreshing = contentSessionCounts.refreshing || contentSessionCounts.pendingMediaSessions > 0 const isSnsCardStatsLoading = !hasSeededSnsStats const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length @@ -3873,17 +3696,12 @@ function ExportPage() { const Icon = card.icon const isCardStatsLoading = card.type === 'sns' ? isSnsCardStatsLoading - : isSessionCardStatsLoading + : false const isCardRunning = runningCardTypes.has(card.type) return (
{card.label}
- {card.type !== 'sns' && !isCardStatsLoading && isSessionCardStatsRefreshing && ( - - 刷新中 - - )}
{card.stats.map((stat) => ( @@ -4112,17 +3930,17 @@ function ExportPage() { {avatarCacheUpdatedAtLabel ? ` · 更新于 ${avatarCacheUpdatedAtLabel}` : ''} )} - {(isContactsListLoading || contactsAvatarEnrichProgress.running) && contactsList.length > 0 && ( + {(isContactsListLoading || isCurrentTabAvatarEnrichRunning) && contactsList.length > 0 && ( 后台同步中... )} - {contactsAvatarEnrichProgress.running && ( + {isCurrentTabAvatarEnrichRunning && ( 头像补全中 {contactsAvatarEnrichProgress.loaded}/{contactsAvatarEnrichProgress.total} )}
- {contactsList.length > 0 && (isContactsListLoading || contactsAvatarEnrichProgress.running) && ( + {contactsList.length > 0 && (isContactsListLoading || isCurrentTabAvatarEnrichRunning) && (
{isContactsListLoading ? '联系人列表同步中…' : '正在补充头像…'} @@ -4513,7 +4331,7 @@ function ExportPage() { {shouldShowFormatSection && (
-

对话文本导出格式选择

+

{exportDialog.scope === 'sns' ? '朋友圈导出格式选择' : '对话文本导出格式选择'}

{isContentTextDialog && (
说明:此模式默认导出头像,不导出图片、语音、视频、表情包等媒体内容。
)} @@ -4521,8 +4339,16 @@ function ExportPage() { {formatCandidateOptions.map(option => ( +
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 9536f6e..939eee0 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -160,30 +160,10 @@ export interface ElectronAPI { counts?: Record error?: string }> - getExportContentSessionCounts: (options?: { - triggerRefresh?: boolean - forceRefresh?: boolean - traceId?: string - }) => Promise<{ - success: boolean - data?: { - totalSessions: number - textSessions: number - voiceSessions: number - imageSessions: number - videoSessions: number - emojiSessions: number - pendingMediaSessions: number - updatedAt: number - refreshing: boolean - } - error?: string - }> - refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean; traceId?: string }) => Promise<{ - success: boolean - error?: string - }> - enrichSessionsContactInfo: (usernames: string[]) => Promise<{ + enrichSessionsContactInfo: ( + usernames: string[], + options?: { skipDisplayName?: boolean; onlyMissingAvatar?: boolean } + ) => Promise<{ success: boolean contacts?: Record error?: string @@ -736,7 +716,7 @@ export interface ElectronAPI { downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }> exportTimeline: (options: { outputDir: string - format: 'json' | 'html' + format: 'json' | 'html' | 'arkmejson' usernames?: string[] keyword?: string exportMedia?: boolean