diff --git a/electron/main.ts b/electron/main.ts index cb55797..8264a49 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -21,6 +21,7 @@ import { videoService } from './services/videoService' import { snsService, isVideoUrl } from './services/snsService' import { contactExportService } from './services/contactExportService' import { windowsHelloService } from './services/windowsHelloService' +import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' @@ -707,6 +708,26 @@ function registerIpcHandlers() { } }) + ipcMain.handle('diagnostics:getExportCardLogs', async (_, options?: { limit?: number }) => { + return exportCardDiagnosticsService.snapshot(options?.limit) + }) + + ipcMain.handle('diagnostics:clearExportCardLogs', async () => { + exportCardDiagnosticsService.clear() + return { success: true } + }) + + ipcMain.handle('diagnostics:exportExportCardLogs', async (_, payload?: { + filePath?: string + frontendLogs?: unknown[] + }) => { + const filePath = typeof payload?.filePath === 'string' ? payload.filePath.trim() : '' + if (!filePath) { + return { success: false, error: '导出路径不能为空' } + } + return exportCardDiagnosticsService.exportCombinedLogs(filePath, payload?.frontendLogs || []) + }) + ipcMain.handle('app:checkForUpdates', async () => { if (!AUTO_UPDATE_ENABLED) { return { hasUpdate: false } @@ -973,14 +994,108 @@ function registerIpcHandlers() { ipcMain.handle('chat:getExportContentSessionCounts', async (_, options?: { triggerRefresh?: boolean forceRefresh?: boolean + traceId?: string }) => { - return chatService.getExportContentSessionCounts(options) + 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 }) => { - return chatService.refreshExportContentSessionCounts(options) + 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[]) => { diff --git a/electron/preload.ts b/electron/preload.ts index 2391fec..d531424 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -73,6 +73,15 @@ contextBridge.exposeInMainWorld('electronAPI', { debug: (data: any) => ipcRenderer.send('log:debug', data) }, + diagnostics: { + getExportCardLogs: (options?: { limit?: number }) => + ipcRenderer.invoke('diagnostics:getExportCardLogs', options), + clearExportCardLogs: () => + ipcRenderer.invoke('diagnostics:clearExportCardLogs'), + exportExportCardLogs: (payload: { filePath: string; frontendLogs?: unknown[] }) => + ipcRenderer.invoke('diagnostics:exportExportCardLogs', payload) + }, + // 窗口控制 window: { minimize: () => ipcRenderer.send('window:minimize'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 5b6313b..bfd3b7d 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -19,6 +19,7 @@ import { ExportContentSessionStatsEntry, ExportContentStatsCacheService } from './exportContentStatsCacheService' +import { exportCardDiagnosticsService } from './exportCardDiagnosticsService' import { voiceTranscribeService } from './voiceTranscribeService' import { LRUCache } from '../utils/LRUCache.js' @@ -908,15 +909,32 @@ class ChatService { */ async getSessionMessageCounts( sessionIds: string[], - options?: { preferHintCache?: boolean; bypassSessionCache?: boolean } + options?: { preferHintCache?: boolean; bypassSessionCache?: boolean; traceId?: string } ): Promise<{ success: boolean counts?: Record error?: string }> { + const traceId = this.normalizeExportDiagTraceId(options?.traceId) + const stepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + message: '开始批量读取会话消息总数', + data: { + requestedSessions: Array.isArray(sessionIds) ? sessionIds.length : 0, + preferHintCache: options?.preferHintCache !== false, + bypassSessionCache: options?.bypassSessionCache === true + } + }) + let success = false + let errorMessage = '' + let returnedCounts = 0 + try { const connectResult = await this.ensureConnected() if (!connectResult.success) { + errorMessage = connectResult.error || '数据库未连接' return { success: false, error: connectResult.error || '数据库未连接' } } @@ -928,6 +946,7 @@ class ChatService { ) ) if (normalizedSessionIds.length === 0) { + success = true return { success: true, counts: {} } } @@ -966,6 +985,18 @@ class ChatService { const batchSize = 320 for (let i = 0; i < pendingSessionIds.length; i += batchSize) { const batch = pendingSessionIds.slice(i, i + batchSize) + this.logExportDiag({ + traceId, + level: 'debug', + source: 'backend', + stepId: 'backend-get-session-message-counts-batch', + stepName: '会话消息总数批次查询', + status: 'running', + message: `开始查询批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(pendingSessionIds.length / batchSize) || 1}`, + data: { + batchSize: batch.length + } + }) let batchCounts: Record = {} try { const result = await wcdbService.getMessageCounts(batch) @@ -988,10 +1019,23 @@ class ChatService { } } + returnedCounts = Object.keys(counts).length + success = true return { success: true, counts } } catch (e) { console.error('ChatService: 批量获取会话消息总数失败:', e) + errorMessage = String(e) return { success: false, error: String(e) } + } finally { + this.endExportDiagStep({ + traceId, + stepId: 'backend-get-session-message-counts', + stepName: 'ChatService.getSessionMessageCounts', + startedAt: stepStartedAt, + success, + message: success ? '批量会话消息总数读取完成' : '批量会话消息总数读取失败', + data: success ? { returnedCounts } : { error: errorMessage || '未知错误' } + }) } } @@ -1628,6 +1672,82 @@ class ChatService { await Promise.all(runners) } + private normalizeExportDiagTraceId(traceId?: string): string { + const normalized = String(traceId || '').trim() + return normalized + } + + private logExportDiag(input: { + traceId?: string + source?: 'backend' | 'main' | 'frontend' | 'worker' + level?: 'debug' | 'info' | 'warn' | 'error' + message: string + stepId?: string + stepName?: string + status?: 'running' | 'done' | 'failed' | 'timeout' + durationMs?: number + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.log({ + traceId, + source: input.source || 'backend', + level: input.level || 'info', + message: input.message, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: input.durationMs, + data: input.data + }) + } + + private startExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + message: string + data?: Record + }): number { + const startedAt = Date.now() + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (traceId) { + exportCardDiagnosticsService.stepStart({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + message: input.message, + data: input.data + }) + } + return startedAt + } + + private endExportDiagStep(input: { + traceId?: string + stepId: string + stepName: string + startedAt: number + success: boolean + message?: string + data?: Record + }): void { + const traceId = this.normalizeExportDiagTraceId(input.traceId) + if (!traceId) return + exportCardDiagnosticsService.stepEnd({ + traceId, + stepId: input.stepId, + stepName: input.stepName, + source: 'backend', + status: input.success ? 'done' : 'failed', + message: input.message || (input.success ? `${input.stepName} 完成` : `${input.stepName} 失败`), + durationMs: Math.max(0, Date.now() - input.startedAt), + data: input.data + }) + } + private refreshSessionMessageCountCacheScope(): void { const dbPath = String(this.configService.get('dbPath') || '') const myWxid = String(this.configService.get('myWxid') || '') @@ -1688,35 +1808,75 @@ class ChatService { this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry) } - private async listExportContentScopeSessionIds(force = false): Promise { + 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 } - const sessionsResult = await this.getSessions() - if (!sessionsResult.success || !sessionsResult.sessions) { - return [] - } + 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_')) + 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 + 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 || '未知错误' } + }) } - return ids } private createDefaultExportContentEntry(): ExportContentSessionStatsEntry { @@ -1735,10 +1895,28 @@ class ChatService { return this.exportContentStatsDirtySessionIds.has(sessionId) } - private async collectExportContentEntry(sessionId: string): Promise { + 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(), @@ -1748,53 +1926,111 @@ class ChatService { const cursor = cursorResult.cursor 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 + 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 + if (entry.hasVoice && entry.hasImage && entry.hasVideo && entry.hasEmoji) { + done = true + break + } + } + + if (!batch.hasMore || rows.length === 0) { 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) } - - entry.mediaReady = true - entry.updatedAt = Date.now() - return entry } - private async startExportContentStatsRefresh(force = false): Promise { + 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) + const sessionIds = await this.listExportContentScopeSessionIds(force, traceId) const sessionIdSet = new Set(sessionIds) const targets: string[] = [] @@ -1806,9 +2042,22 @@ class ChatService { } 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) + const nextEntry = await this.collectExportContentEntry(sessionId, traceId) this.exportContentStatsMemory.set(sessionId, nextEntry) if (nextEntry.mediaReady) { this.exportContentStatsDirtySessionIds.delete(sessionId) @@ -1836,13 +2085,35 @@ class ChatService { 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) + void this.startExportContentStatsRefresh(rerunForce, traceId) } } } @@ -1850,17 +2121,34 @@ class ChatService { async getExportContentSessionCounts(options?: { triggerRefresh?: boolean forceRefresh?: boolean + traceId?: string }): Promise<{ success: boolean; data?: ExportContentSessionCounts; error?: string }> { + const traceId = this.normalizeExportDiagTraceId(options?.traceId) + const stepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-get-export-content-session-counts', + stepName: '获取导出卡片统计', + message: '开始计算导出卡片统计', + data: { + triggerRefresh: options?.triggerRefresh !== false, + forceRefresh: options?.forceRefresh === true + } + }) + let stepSuccess = false + let stepError = '' + let stepResult: ExportContentSessionCounts | undefined + try { const connectResult = await this.ensureConnected() if (!connectResult.success) { + stepError = connectResult.error || '数据库未连接' return { success: false, error: connectResult.error || '数据库未连接' } } this.refreshSessionMessageCountCacheScope() const forceRefresh = options?.forceRefresh === true const triggerRefresh = options?.triggerRefresh !== false - const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh) + const sessionIds = await this.listExportContentScopeSessionIds(forceRefresh, traceId) const sessionIdSet = new Set(sessionIds) for (const sessionId of Array.from(this.exportContentStatsMemory.keys())) { @@ -1906,10 +2194,34 @@ class ChatService { } if (missingTextCountSessionIds.length > 0) { + const textCountStepStartedAt = this.startExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + message: '开始补全文本会话计数', + data: { missingSessions: missingTextCountSessionIds.length } + }) + const textCountStallTimer = setTimeout(() => { + this.logExportDiag({ + traceId, + source: 'backend', + level: 'warn', + message: '补全文本会话计数耗时较长', + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + status: 'running', + data: { + elapsedMs: Date.now() - textCountStepStartedAt, + missingSessions: missingTextCountSessionIds.length + } + }) + }, 3000) const textCountResult = await this.getSessionMessageCounts(missingTextCountSessionIds, { preferHintCache: false, - bypassSessionCache: true + bypassSessionCache: true, + traceId }) + clearTimeout(textCountStallTimer) if (textCountResult.success && textCountResult.counts) { const now = Date.now() for (const sessionId of missingTextCountSessionIds) { @@ -1927,47 +2239,109 @@ class ChatService { } } this.persistExportContentStatsScope(sessionIdSet) + this.endExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + startedAt: textCountStepStartedAt, + success: true, + message: '文本会话计数补全完成', + data: { updatedSessions: missingTextCountSessionIds.length } + }) + } else { + this.endExportDiagStep({ + traceId, + stepId: 'backend-fill-text-counts', + stepName: '补全文本会话计数', + startedAt: textCountStepStartedAt, + success: false, + message: '文本会话计数补全失败', + data: { error: textCountResult.error || '未知错误' } + }) } } if (forceRefresh && triggerRefresh) { - void this.startExportContentStatsRefresh(true) + void this.startExportContentStatsRefresh(true, traceId) } else if (triggerRefresh && (pendingMediaSessionSet.size > 0 || this.exportContentStatsDirtySessionIds.size > 0)) { - void this.startExportContentStatsRefresh(false) + void this.startExportContentStatsRefresh(false, traceId) } + stepResult = { + totalSessions: sessionIds.length, + textSessions, + voiceSessions, + imageSessions, + videoSessions, + emojiSessions, + pendingMediaSessions: pendingMediaSessionSet.size, + updatedAt: this.exportContentStatsScopeUpdatedAt, + refreshing: this.exportContentStatsRefreshPromise !== null + } + stepSuccess = true return { success: true, - data: { - totalSessions: sessionIds.length, - textSessions, - voiceSessions, - imageSessions, - videoSessions, - emojiSessions, - pendingMediaSessions: pendingMediaSessionSet.size, - updatedAt: this.exportContentStatsScopeUpdatedAt, - refreshing: this.exportContentStatsRefreshPromise !== null - } + data: stepResult } } catch (e) { console.error('ChatService: 获取导出内容会话统计失败:', e) + stepError = String(e) return { success: false, error: String(e) } + } finally { + 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 }): Promise<{ success: boolean; error?: string }> { + 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() - await this.startExportContentStatsRefresh(options?.forceRefresh === true) + await this.startExportContentStatsRefresh(options?.forceRefresh === true, traceId) + 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 || '未知错误' } + }) } } diff --git a/electron/services/exportCardDiagnosticsService.ts b/electron/services/exportCardDiagnosticsService.ts new file mode 100644 index 0000000..37768a0 --- /dev/null +++ b/electron/services/exportCardDiagnosticsService.ts @@ -0,0 +1,354 @@ +import { mkdir, writeFile } from 'fs/promises' +import { basename, dirname, extname, join } from 'path' + +export type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' +export type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error' +export type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout' + +export interface ExportCardDiagLogEntry { + id: string + ts: number + source: ExportCardDiagSource + level: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +interface ActiveStepState { + key: string + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + startedAt: number + lastUpdatedAt: number + message?: string +} + +interface StepStartInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message?: string + data?: Record +} + +interface StepEndInput { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + status?: Extract + level?: ExportCardDiagLevel + message?: string + data?: Record + durationMs?: number +} + +interface LogInput { + ts?: number + source: ExportCardDiagSource + level?: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +export interface ExportCardDiagSnapshot { + logs: ExportCardDiagLogEntry[] + activeSteps: Array<{ + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + elapsedMs: number + stallMs: number + startedAt: number + lastUpdatedAt: number + message?: string + }> + summary: { + totalLogs: number + activeStepCount: number + errorCount: number + warnCount: number + timeoutCount: number + lastUpdatedAt: number + } +} + +export class ExportCardDiagnosticsService { + private readonly maxLogs = 6000 + private logs: ExportCardDiagLogEntry[] = [] + private activeSteps = new Map() + private seq = 0 + + private nextId(ts: number): string { + this.seq += 1 + return `export-card-diag-${ts}-${this.seq}` + } + + private trimLogs() { + if (this.logs.length <= this.maxLogs) return + const drop = this.logs.length - this.maxLogs + this.logs.splice(0, drop) + } + + log(input: LogInput): ExportCardDiagLogEntry { + const ts = Number.isFinite(input.ts) ? Math.max(0, Math.floor(input.ts as number)) : Date.now() + const entry: ExportCardDiagLogEntry = { + id: this.nextId(ts), + ts, + source: input.source, + level: input.level || 'info', + message: input.message, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: Number.isFinite(input.durationMs) ? Math.max(0, Math.floor(input.durationMs as number)) : undefined, + data: input.data + } + + this.logs.push(entry) + this.trimLogs() + + if (entry.traceId && entry.stepId && entry.stepName) { + const key = `${entry.traceId}::${entry.stepId}` + if (entry.status === 'running') { + const previous = this.activeSteps.get(key) + this.activeSteps.set(key, { + key, + traceId: entry.traceId, + stepId: entry.stepId, + stepName: entry.stepName, + source: entry.source, + startedAt: previous?.startedAt || entry.ts, + lastUpdatedAt: entry.ts, + message: entry.message + }) + } else if (entry.status === 'done' || entry.status === 'failed' || entry.status === 'timeout') { + this.activeSteps.delete(key) + } + } + + return entry + } + + stepStart(input: StepStartInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || 'info', + message: input.message || `${input.stepName} 开始`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: 'running', + data: input.data + }) + } + + stepEnd(input: StepEndInput): ExportCardDiagLogEntry { + return this.log({ + source: input.source, + level: input.level || (input.status === 'done' ? 'info' : 'warn'), + message: input.message || `${input.stepName} ${input.status === 'done' ? '完成' : '结束'}`, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status || 'done', + durationMs: input.durationMs, + data: input.data + }) + } + + clear() { + this.logs = [] + this.activeSteps.clear() + } + + snapshot(limit = 1200): ExportCardDiagSnapshot { + const capped = Number.isFinite(limit) ? Math.max(100, Math.min(5000, Math.floor(limit))) : 1200 + const logs = this.logs.slice(-capped) + const now = Date.now() + + const activeSteps = Array.from(this.activeSteps.values()) + .map(step => ({ + traceId: step.traceId, + stepId: step.stepId, + stepName: step.stepName, + source: step.source, + startedAt: step.startedAt, + lastUpdatedAt: step.lastUpdatedAt, + elapsedMs: Math.max(0, now - step.startedAt), + stallMs: Math.max(0, now - step.lastUpdatedAt), + message: step.message + })) + .sort((a, b) => b.lastUpdatedAt - a.lastUpdatedAt) + + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + } + + return { + logs, + activeSteps, + summary: { + totalLogs: this.logs.length, + activeStepCount: activeSteps.length, + errorCount, + warnCount, + timeoutCount, + lastUpdatedAt: logs.length > 0 ? logs[logs.length - 1].ts : 0 + } + } + } + + private normalizeExternalLogs(value: unknown[]): ExportCardDiagLogEntry[] { + const result: ExportCardDiagLogEntry[] = [] + for (const item of value) { + if (!item || typeof item !== 'object') continue + const row = item as Record + const tsRaw = row.ts ?? row.timestamp + const tsNum = Number(tsRaw) + const ts = Number.isFinite(tsNum) && tsNum > 0 ? Math.floor(tsNum) : Date.now() + + const sourceRaw = String(row.source || 'frontend') + const source: ExportCardDiagSource = sourceRaw === 'main' || sourceRaw === 'backend' || sourceRaw === 'worker' + ? sourceRaw + : 'frontend' + const levelRaw = String(row.level || 'info') + const level: ExportCardDiagLevel = levelRaw === 'debug' || levelRaw === 'warn' || levelRaw === 'error' + ? levelRaw + : 'info' + + const statusRaw = String(row.status || '') + const status: ExportCardDiagStatus | undefined = statusRaw === 'running' || statusRaw === 'done' || statusRaw === 'failed' || statusRaw === 'timeout' + ? statusRaw + : undefined + + const durationRaw = Number(row.durationMs) + result.push({ + id: String(row.id || this.nextId(ts)), + ts, + source, + level, + message: String(row.message || ''), + traceId: typeof row.traceId === 'string' ? row.traceId : undefined, + stepId: typeof row.stepId === 'string' ? row.stepId : undefined, + stepName: typeof row.stepName === 'string' ? row.stepName : undefined, + status, + durationMs: Number.isFinite(durationRaw) ? Math.max(0, Math.floor(durationRaw)) : undefined, + data: row.data && typeof row.data === 'object' ? row.data as Record : undefined + }) + } + return result + } + + private serializeLogEntry(log: ExportCardDiagLogEntry): string { + return JSON.stringify(log) + } + + private buildSummaryText(logs: ExportCardDiagLogEntry[], activeSteps: ExportCardDiagSnapshot['activeSteps']): string { + const total = logs.length + let errorCount = 0 + let warnCount = 0 + let timeoutCount = 0 + let frontendCount = 0 + let backendCount = 0 + let mainCount = 0 + let workerCount = 0 + + for (const item of logs) { + if (item.level === 'error') errorCount += 1 + if (item.level === 'warn') warnCount += 1 + if (item.status === 'timeout') timeoutCount += 1 + if (item.source === 'frontend') frontendCount += 1 + if (item.source === 'backend') backendCount += 1 + if (item.source === 'main') mainCount += 1 + if (item.source === 'worker') workerCount += 1 + } + + const lines: string[] = [] + lines.push('WeFlow 导出卡片诊断摘要') + lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`) + lines.push(`日志总数: ${total}`) + lines.push(`来源统计: frontend=${frontendCount}, main=${mainCount}, backend=${backendCount}, worker=${workerCount}`) + lines.push(`级别统计: warn=${warnCount}, error=${errorCount}, timeout=${timeoutCount}`) + lines.push(`当前活跃步骤: ${activeSteps.length}`) + + if (activeSteps.length > 0) { + lines.push('') + lines.push('活跃步骤:') + for (const step of activeSteps.slice(0, 12)) { + lines.push(`- [${step.source}] ${step.stepName} trace=${step.traceId} elapsed=${step.elapsedMs}ms stall=${step.stallMs}ms`) + } + } + + const latestErrors = logs.filter(item => item.level === 'error' || item.status === 'failed' || item.status === 'timeout').slice(-12) + if (latestErrors.length > 0) { + lines.push('') + lines.push('最近异常:') + for (const item of latestErrors) { + lines.push(`- ${new Date(item.ts).toLocaleTimeString('zh-CN')} [${item.source}] ${item.stepName || item.stepId || 'unknown'} ${item.status || item.level} ${item.message}`) + } + } + + return lines.join('\n') + } + + async exportCombinedLogs(filePath: string, frontendLogs: unknown[] = []): Promise<{ + success: boolean + filePath?: string + summaryPath?: string + count?: number + error?: string + }> { + try { + const normalizedFrontend = this.normalizeExternalLogs(Array.isArray(frontendLogs) ? frontendLogs : []) + const merged = [...this.logs, ...normalizedFrontend] + .sort((a, b) => (a.ts - b.ts) || a.id.localeCompare(b.id)) + + const lines = merged.map(item => this.serializeLogEntry(item)).join('\n') + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, lines ? `${lines}\n` : '', 'utf8') + + const ext = extname(filePath) + const baseName = ext ? basename(filePath, ext) : basename(filePath) + const summaryPath = join(dirname(filePath), `${baseName}.txt`) + const snapshot = this.snapshot(1500) + const summaryText = this.buildSummaryText(merged, snapshot.activeSteps) + await writeFile(summaryPath, summaryText, 'utf8') + + return { + success: true, + filePath, + summaryPath, + count: merged.length + } + } catch (error) { + return { + success: false, + error: String(error) + } + } + } +} + +export const exportCardDiagnosticsService = new ExportCardDiagnosticsService() diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 290281f..a9a814b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -379,6 +379,267 @@ } } +.export-card-diagnostics-section { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--card-bg); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.diag-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.diag-panel-title { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + color: var(--text-primary); + font-weight: 600; +} + +.diag-panel-subtitle { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 500; +} + +.diag-panel-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.diag-overview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; +} + +.diag-overview-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + display: grid; + gap: 4px; + background: var(--bg-secondary); + font-size: 11px; + color: var(--text-secondary); + + strong { + font-size: 16px; + line-height: 1; + color: var(--text-primary); + } + + .warn { + color: #ff4d4f; + } +} + +.diag-step-chain { + border: 1px dashed var(--border-color); + border-radius: 10px; + padding: 10px; + background: var(--bg-secondary); + display: grid; + gap: 8px; +} + +.diag-step-chain-title { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.diag-step-list { + display: grid; + gap: 6px; +} + +.diag-step-item { + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 7px 8px; + background: var(--bg-primary); + display: flex; + align-items: flex-start; + gap: 8px; + + &.running { + border-color: rgba(var(--primary-rgb), 0.4); + } + + &.done { + border-color: rgba(82, 196, 26, 0.4); + } + + &.failed, + &.timeout, + &.stalled { + border-color: rgba(255, 77, 79, 0.45); + } +} + +.diag-step-order { + min-width: 18px; + height: 18px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + font-size: 11px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + line-height: 1; +} + +.diag-step-main { + min-width: 0; + display: grid; + gap: 3px; +} + +.diag-step-name { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.diag-step-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 11px; + color: var(--text-secondary); + + .warn { + color: #ff4d4f; + font-weight: 600; + } +} + +.diag-log-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.diag-filter-btn { + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 11px; + padding: 5px 10px; + cursor: pointer; + + &.active { + border-color: var(--primary); + color: var(--primary); + background: rgba(var(--primary-rgb), 0.12); + } +} + +.diag-log-list { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--bg-secondary); + max-height: 320px; + overflow-y: auto; + padding: 8px; + display: grid; + gap: 6px; +} + +.diag-log-item { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + padding: 6px 8px; + display: grid; + gap: 4px; + + &.warn { + border-color: rgba(250, 173, 20, 0.5); + } + + &.error { + border-color: rgba(255, 77, 79, 0.45); + } +} + +.diag-log-top { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.diag-log-time { + font-size: 11px; + color: var(--text-tertiary); +} + +.diag-log-tag { + border-radius: 999px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 10px; + padding: 1px 6px; + line-height: 1.4; + + &.warn, + &.timeout { + border-color: rgba(250, 173, 20, 0.55); + color: #d48806; + } + + &.error, + &.failed { + border-color: rgba(255, 77, 79, 0.55); + color: #ff4d4f; + } + + &.done { + border-color: rgba(82, 196, 26, 0.5); + color: #52c41a; + } +} + +.diag-log-message { + font-size: 12px; + color: var(--text-primary); + line-height: 1.45; +} + +.diag-log-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-size: 11px; + color: var(--text-tertiary); +} + +.diag-empty { + color: var(--text-secondary); + font-size: 12px; +} + .count-loading { color: var(--text-tertiary); font-size: 12px; @@ -1985,6 +2246,20 @@ } @media (max-width: 720px) { + .diag-panel-header { + flex-direction: column; + align-items: stretch; + } + + .diag-panel-actions { + width: 100%; + } + + .diag-panel-actions .secondary-btn { + flex: 1; + justify-content: center; + } + .export-dialog-overlay { padding: 10px; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 9b85cc1..4449db0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -463,6 +463,7 @@ const getContactTypeName = (type: ContactInfo['type']): string => { } const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +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 @@ -470,6 +471,9 @@ const EXPORT_AVATAR_ENRICH_BATCH_SIZE = 80 const CONTACTS_LIST_VIRTUAL_ROW_HEIGHT = 76 const CONTACTS_LIST_VIRTUAL_OVERSCAN = 10 const DEFAULT_CONTACTS_LOAD_TIMEOUT_MS = 3000 +const EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS = 1500 +const EXPORT_CARD_DIAG_STALL_MS = 3200 +const EXPORT_CARD_DIAG_POLL_INTERVAL_MS = 1200 type SessionDataSource = 'cache' | 'network' | null type ContactsDataSource = 'cache' | 'network' | null @@ -549,6 +553,51 @@ interface ExportContentSessionCountsSummary { refreshing: boolean } +type ExportCardDiagFilter = 'all' | 'frontend' | 'main' | 'backend' | 'worker' | 'warn' | 'error' + +type ExportCardDiagSource = 'frontend' | 'main' | 'backend' | 'worker' +type ExportCardDiagLevel = 'debug' | 'info' | 'warn' | 'error' +type ExportCardDiagStatus = 'running' | 'done' | 'failed' | 'timeout' + +interface ExportCardDiagLogEntry { + id: string + ts: number + source: ExportCardDiagSource + level: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record +} + +interface ExportCardDiagActiveStep { + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + elapsedMs: number + stallMs: number + startedAt: number + lastUpdatedAt: number + message?: string +} + +interface ExportCardDiagSnapshotState { + logs: ExportCardDiagLogEntry[] + activeSteps: ExportCardDiagActiveStep[] + summary: { + totalLogs: number + activeStepCount: number + errorCount: number + warnCount: number + timeoutCount: number + lastUpdatedAt: number + } +} + const defaultContentSessionCounts: ExportContentSessionCountsSummary = { totalSessions: 0, textSessions: 0, @@ -561,6 +610,19 @@ const defaultContentSessionCounts: ExportContentSessionCountsSummary = { refreshing: false } +const defaultExportCardDiagSnapshot: ExportCardDiagSnapshotState = { + logs: [], + activeSteps: [], + summary: { + totalLogs: 0, + activeStepCount: 0, + errorCount: 0, + warnCount: 0, + timeoutCount: 0, + lastUpdatedAt: 0 + } +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -878,6 +940,11 @@ function ExportPage() { 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') + const [frontendDiagLogs, setFrontendDiagLogs] = useState([]) + const [backendDiagSnapshot, setBackendDiagSnapshot] = useState(defaultExportCardDiagSnapshot) + const [isExportCardDiagSyncing, setIsExportCardDiagSyncing] = useState(false) const [nowTick, setNowTick] = useState(Date.now()) const tabCounts = useContactTypeCountsStore(state => state.tabCounts) const isSharedTabCountsLoading = useContactTypeCountsStore(state => state.isLoading) @@ -905,6 +972,63 @@ function ExportPage() { const hasBaseConfigReadyRef = useRef(false) const contentSessionCountsForceRetryRef = useRef(0) + const appendFrontendDiagLog = useCallback((entry: ExportCardDiagLogEntry) => { + setFrontendDiagLogs(prev => { + const next = [...prev, entry] + if (next.length > EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS) { + return next.slice(next.length - EXPORT_CARD_DIAG_MAX_FRONTEND_LOGS) + } + return next + }) + }, []) + + const logFrontendDiag = useCallback((input: { + source?: ExportCardDiagSource + level?: ExportCardDiagLevel + message: string + traceId?: string + stepId?: string + stepName?: string + status?: ExportCardDiagStatus + durationMs?: number + data?: Record + }) => { + const ts = Date.now() + appendFrontendDiagLog({ + id: `frontend-diag-${ts}-${Math.random().toString(36).slice(2, 8)}`, + ts, + source: input.source || 'frontend', + level: input.level || 'info', + message: input.message, + traceId: input.traceId, + stepId: input.stepId, + stepName: input.stepName, + status: input.status, + durationMs: input.durationMs, + data: input.data + }) + }, [appendFrontendDiagLog]) + + const fetchExportCardDiagnosticsSnapshot = useCallback(async (limit = 1200) => { + setIsExportCardDiagSyncing(true) + try { + const snapshot = await window.electronAPI.diagnostics.getExportCardLogs({ limit }) + if (!snapshot || typeof snapshot !== 'object') return + setBackendDiagSnapshot(snapshot as ExportCardDiagSnapshotState) + } catch (error) { + logFrontendDiag({ + level: 'warn', + message: '拉取后端诊断日志失败', + stepId: 'frontend-sync-backend-diag', + stepName: '同步后端诊断日志', + status: 'failed', + data: { error: String(error) } + }) + } finally { + setIsExportCardDiagSyncing(false) + } + }, [logFrontendDiag]) + const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { return exportCacheScopeRef.current @@ -1413,14 +1537,40 @@ function ExportPage() { }, []) const loadContentSessionCounts = useCallback(async (options?: { silent?: boolean; forceRefresh?: boolean }) => { + const traceId = createExportDiagTraceId() + const startedAt = Date.now() + 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 + 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, @@ -1443,16 +1593,76 @@ function ExportPage() { if (looksLikeAllZero && contentSessionCountsForceRetryRef.current < 3) { contentSessionCountsForceRetryRef.current += 1 - void window.electronAPI.chat.refreshExportContentSessionCounts({ forceRefresh: true }) + 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) } + }) } - }, []) + }, [logFrontendDiag]) const loadSessions = useCallback(async () => { const loadToken = Date.now() @@ -1718,6 +1928,15 @@ function ExportPage() { return () => window.clearInterval(timer) }, [isExportRoute, loadContentSessionCounts]) + useEffect(() => { + if (!isExportRoute || !showCardDiagnostics) return + void fetchExportCardDiagnosticsSnapshot(1600) + const timer = window.setInterval(() => { + void fetchExportCardDiagnosticsSnapshot(1600) + }, EXPORT_CARD_DIAG_POLL_INTERVAL_MS) + return () => window.clearInterval(timer) + }, [isExportRoute, showCardDiagnostics, fetchExportCardDiagnosticsSnapshot]) + useEffect(() => { if (isExportRoute) return // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 @@ -2621,6 +2840,147 @@ function ExportPage() { return [...sessionCards, snsCard] }, [sessions, contentSessionCounts, lastExportByContent, snsStats, lastSnsExportPostCount]) + const mergedCardDiagLogs = useMemo(() => { + const merged = [...backendDiagSnapshot.logs, ...frontendDiagLogs] + merged.sort((a, b) => (b.ts - a.ts) || a.id.localeCompare(b.id)) + return merged + }, [backendDiagSnapshot.logs, frontendDiagLogs]) + + const latestCardDiagTraceId = useMemo(() => { + for (const item of mergedCardDiagLogs) { + const traceId = String(item.traceId || '').trim() + if (traceId) return traceId + } + return '' + }, [mergedCardDiagLogs]) + + const cardDiagTraceSteps = useMemo(() => { + if (!latestCardDiagTraceId) return [] as Array<{ + traceId: string + stepId: string + stepName: string + source: ExportCardDiagSource + status: ExportCardDiagStatus + startedAt: number + endedAt?: number + durationMs?: number + lastUpdatedAt: number + message: string + stalled: boolean + }> + + const traceLogs = mergedCardDiagLogs + .filter(item => item.traceId === latestCardDiagTraceId && item.stepId && item.stepName) + .sort((a, b) => a.ts - b.ts) + + const stepMap = new Map() + + for (const item of traceLogs) { + const stepId = String(item.stepId || '').trim() + if (!stepId) continue + const prev = stepMap.get(stepId) + const nextStatus: ExportCardDiagStatus = item.status || prev?.status || 'running' + const startedAt = prev?.startedAt || item.ts + const endedAt = nextStatus === 'done' || nextStatus === 'failed' || nextStatus === 'timeout' + ? item.ts + : prev?.endedAt + const durationMs = typeof item.durationMs === 'number' + ? item.durationMs + : endedAt + ? Math.max(0, endedAt - startedAt) + : undefined + stepMap.set(stepId, { + traceId: latestCardDiagTraceId, + stepId, + stepName: String(item.stepName || stepId), + source: item.source, + status: nextStatus, + startedAt, + endedAt, + durationMs, + lastUpdatedAt: item.ts, + message: item.message + }) + } + + const now = Date.now() + return Array.from(stepMap.values()).map(step => ({ + ...step, + stalled: step.status === 'running' && now - step.lastUpdatedAt >= EXPORT_CARD_DIAG_STALL_MS + })) + }, [mergedCardDiagLogs, latestCardDiagTraceId]) + + const cardDiagRunningStepCount = useMemo( + () => cardDiagTraceSteps.filter(step => step.status === 'running').length, + [cardDiagTraceSteps] + ) + const cardDiagStalledStepCount = useMemo( + () => cardDiagTraceSteps.filter(step => step.stalled).length, + [cardDiagTraceSteps] + ) + + const filteredCardDiagLogs = useMemo(() => { + return mergedCardDiagLogs.filter((item) => { + if (diagFilter === 'all') return true + if (diagFilter === 'warn') return item.level === 'warn' + if (diagFilter === 'error') return item.level === 'error' || item.status === 'failed' || item.status === 'timeout' + return item.source === diagFilter + }) + }, [mergedCardDiagLogs, diagFilter]) + + const clearCardDiagnostics = useCallback(async () => { + setFrontendDiagLogs([]) + setBackendDiagSnapshot(defaultExportCardDiagSnapshot) + try { + await window.electronAPI.diagnostics.clearExportCardLogs() + } catch (error) { + logFrontendDiag({ + level: 'warn', + message: '清空后端诊断日志失败', + stepId: 'frontend-clear-diagnostics', + stepName: '清空诊断日志', + status: 'failed', + data: { error: String(error) } + }) + } + }, [logFrontendDiag]) + + const exportCardDiagnosticsLogs = useCallback(async () => { + const now = new Date() + const stamp = `${now.getFullYear()}${`${now.getMonth() + 1}`.padStart(2, '0')}${`${now.getDate()}`.padStart(2, '0')}-${`${now.getHours()}`.padStart(2, '0')}${`${now.getMinutes()}`.padStart(2, '0')}${`${now.getSeconds()}`.padStart(2, '0')}` + const defaultDir = exportFolder || await window.electronAPI.app.getDownloadsPath() + const saveResult = await window.electronAPI.dialog.saveFile({ + title: '导出导出卡片诊断日志', + defaultPath: `${defaultDir}/weflow-export-card-diagnostics-${stamp}.jsonl`, + filters: [ + { name: 'JSON Lines', extensions: ['jsonl'] }, + { name: 'Text', extensions: ['txt'] } + ] + }) + if (saveResult.canceled || !saveResult.filePath) return + + const result = await window.electronAPI.diagnostics.exportExportCardLogs({ + filePath: saveResult.filePath, + frontendLogs: frontendDiagLogs + }) + if (result.success) { + window.alert(`导出成功\\n日志:${result.filePath}\\n摘要:${result.summaryPath || '未生成'}\\n总条数:${result.count || 0}`) + } else { + window.alert(`导出失败:${result.error || '未知错误'}`) + } + }, [exportFolder, frontendDiagLogs]) + const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' if (activeTab === 'group') return '群聊' @@ -3522,6 +3882,148 @@ function ExportPage() { })} +
+
+
+ 卡片统计诊断日志 + 仅用于当前 6 个卡片排查 +
+
+ + {showCardDiagnostics && ( + <> + + + + + )} +
+
+ + {showCardDiagnostics && ( + <> +
+
+ 日志总数 + {backendDiagSnapshot.summary.totalLogs + frontendDiagLogs.length} +
+
+ 活跃步骤 + {backendDiagSnapshot.activeSteps.length} +
+
+ 当前运行步骤 + {cardDiagRunningStepCount} +
+
+ 疑似卡住 + 0 ? 'warn' : ''}>{cardDiagStalledStepCount} +
+
+ 最近告警 + {backendDiagSnapshot.summary.warnCount} +
+
+ 最近错误 + 0 ? 'warn' : ''}>{backendDiagSnapshot.summary.errorCount} +
+
+ +
+
+ 当前链路 + {latestCardDiagTraceId ? ` · trace=${latestCardDiagTraceId}` : ''} +
+ {cardDiagTraceSteps.length === 0 ? ( +
暂无链路步骤,请先触发一次卡片统计。
+ ) : ( +
+ {cardDiagTraceSteps.map((step, index) => ( +
+ {index + 1} +
+
{step.stepName}
+
+ {step.source} + {step.status} + 耗时 {step.durationMs ?? Math.max(0, Date.now() - step.startedAt)}ms + {step.stalled && 卡住 {Math.max(0, Date.now() - step.lastUpdatedAt)}ms} +
+
+
+ ))} +
+ )} +
+ +
+ {([ + { value: 'all', label: '全部' }, + { value: 'frontend', label: '前端' }, + { value: 'main', label: '主进程' }, + { value: 'backend', label: '后端' }, + { value: 'worker', label: 'Worker' }, + { value: 'warn', label: '告警' }, + { value: 'error', label: '错误' } + ] as Array<{ value: ExportCardDiagFilter; label: string }>).map(item => ( + + ))} +
+ +
+ {filteredCardDiagLogs.length === 0 ? ( +
暂无日志
+ ) : ( + filteredCardDiagLogs.slice(0, 260).map(log => { + const ms = `${log.ts % 1000}`.padStart(3, '0') + const timeLabel = `${new Date(log.ts).toLocaleTimeString('zh-CN', { hour12: false })}.${ms}` + return ( +
+
+ {timeLabel} + {log.source} + {log.level} + {log.status && {log.status}} + {typeof log.durationMs === 'number' && 耗时 {log.durationMs}ms} +
+
{log.message}
+ {(log.stepName || log.traceId) && ( +
+ {log.stepName && {log.stepName}} + {log.traceId && trace={log.traceId}} +
+ )} +
+ ) + }) + )} +
+ + )} +
+
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 374077e..9536f6e 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -61,6 +61,53 @@ export interface ElectronAPI { read: () => Promise<{ success: boolean; content?: string; error?: string }> debug: (data: any) => void } + diagnostics: { + getExportCardLogs: (options?: { limit?: number }) => Promise<{ + logs: Array<{ + id: string + ts: number + source: 'frontend' | 'main' | 'backend' | 'worker' + level: 'debug' | 'info' | 'warn' | 'error' + message: string + traceId?: string + stepId?: string + stepName?: string + status?: 'running' | 'done' | 'failed' | 'timeout' + durationMs?: number + data?: Record + }> + activeSteps: Array<{ + traceId: string + stepId: string + stepName: string + source: 'frontend' | 'main' | 'backend' | 'worker' + elapsedMs: number + stallMs: number + startedAt: number + lastUpdatedAt: number + message?: string + }> + summary: { + totalLogs: number + activeStepCount: number + errorCount: number + warnCount: number + timeoutCount: number + lastUpdatedAt: number + } + }> + clearExportCardLogs: () => Promise<{ success: boolean }> + exportExportCardLogs: (payload: { + filePath: string + frontendLogs?: unknown[] + }) => Promise<{ + success: boolean + filePath?: string + summaryPath?: string + count?: number + error?: string + }> + } dbPath: { autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }> scanWxids: (rootPath: string) => Promise @@ -116,6 +163,7 @@ export interface ElectronAPI { getExportContentSessionCounts: (options?: { triggerRefresh?: boolean forceRefresh?: boolean + traceId?: string }) => Promise<{ success: boolean data?: { @@ -131,7 +179,7 @@ export interface ElectronAPI { } error?: string }> - refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean }) => Promise<{ + refreshExportContentSessionCounts: (options?: { forceRefresh?: boolean; traceId?: string }) => Promise<{ success: boolean error?: string }>