mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): add card stats diagnostics panel and log export
This commit is contained in:
119
electron/main.ts
119
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[]) => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<string, number>
|
||||
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<string, number> = {}
|
||||
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<string, unknown>
|
||||
}): 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<string, unknown>
|
||||
}): 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<string, unknown>
|
||||
}): 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,18 +1808,42 @@ class ChatService {
|
||||
this.exportContentStatsCacheService.setScope(this.exportContentStatsScope, scopeEntry)
|
||||
}
|
||||
|
||||
private async listExportContentScopeSessionIds(force = false): Promise<string[]> {
|
||||
private async listExportContentScopeSessionIds(force = false, traceId?: string): Promise<string[]> {
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionsResult = await this.getSessions()
|
||||
if (!sessionsResult.success || !sessionsResult.sessions) {
|
||||
errorMessage = sessionsResult.error || '获取会话失败'
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1716,7 +1860,23 @@ class ChatService {
|
||||
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 {
|
||||
@@ -1735,10 +1895,28 @@ class ChatService {
|
||||
return this.exportContentStatsDirtySessionIds.has(sessionId)
|
||||
}
|
||||
|
||||
private async collectExportContentEntry(sessionId: string): Promise<ExportContentSessionStatsEntry> {
|
||||
private async collectExportContentEntry(sessionId: string, traceId?: string): Promise<ExportContentSessionStatsEntry> {
|
||||
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(),
|
||||
@@ -1747,6 +1925,7 @@ class ChatService {
|
||||
}
|
||||
|
||||
const cursor = cursorResult.cursor
|
||||
try {
|
||||
try {
|
||||
let done = false
|
||||
while (!done) {
|
||||
@@ -1777,24 +1956,81 @@ class ChatService {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await wcdbService.closeMessageCursor(cursor)
|
||||
} 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): Promise<void> {
|
||||
private async startExportContentStatsRefresh(force = false, traceId?: string): Promise<void> {
|
||||
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,18 +2239,35 @@ 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)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stepResult = {
|
||||
totalSessions: sessionIds.length,
|
||||
textSessions,
|
||||
voiceSessions,
|
||||
@@ -1949,25 +2278,70 @@ class ChatService {
|
||||
updatedAt: this.exportContentStatsScopeUpdatedAt,
|
||||
refreshing: this.exportContentStatsRefreshPromise !== null
|
||||
}
|
||||
stepSuccess = true
|
||||
return {
|
||||
success: true,
|
||||
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 || '未知错误' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
354
electron/services/exportCardDiagnosticsService.ts
Normal file
354
electron/services/exportCardDiagnosticsService.ts
Normal file
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
}
|
||||
|
||||
interface StepEndInput {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
status?: Extract<ExportCardDiagStatus, 'done' | 'failed' | 'timeout'>
|
||||
level?: ExportCardDiagLevel
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
interface LogInput {
|
||||
ts?: number
|
||||
source: ExportCardDiagSource
|
||||
level?: ExportCardDiagLevel
|
||||
message: string
|
||||
traceId?: string
|
||||
stepId?: string
|
||||
stepName?: string
|
||||
status?: ExportCardDiagStatus
|
||||
durationMs?: number
|
||||
data?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<string, ActiveStepState>()
|
||||
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<string, unknown>
|
||||
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<string, unknown> : 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()
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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 <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
try {
|
||||
@@ -878,6 +940,11 @@ function ExportPage() {
|
||||
const [contentSessionCounts, setContentSessionCounts] = useState<ExportContentSessionCountsSummary>(defaultContentSessionCounts)
|
||||
const [hasSeededContentSessionCounts, setHasSeededContentSessionCounts] = useState(false)
|
||||
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
||||
const [showCardDiagnostics, setShowCardDiagnostics] = useState(false)
|
||||
const [diagFilter, setDiagFilter] = useState<ExportCardDiagFilter>('all')
|
||||
const [frontendDiagLogs, setFrontendDiagLogs] = useState<ExportCardDiagLogEntry[]>([])
|
||||
const [backendDiagSnapshot, setBackendDiagSnapshot] = useState<ExportCardDiagSnapshotState>(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<string, unknown>
|
||||
}) => {
|
||||
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<string> => {
|
||||
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<string, {
|
||||
traceId: string
|
||||
stepId: string
|
||||
stepName: string
|
||||
source: ExportCardDiagSource
|
||||
status: ExportCardDiagStatus
|
||||
startedAt: number
|
||||
endedAt?: number
|
||||
durationMs?: number
|
||||
lastUpdatedAt: number
|
||||
message: string
|
||||
}>()
|
||||
|
||||
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() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="export-card-diagnostics-section">
|
||||
<div className="diag-panel-header">
|
||||
<div className="diag-panel-title">
|
||||
<span>卡片统计诊断日志</span>
|
||||
<span className="diag-panel-subtitle">仅用于当前 6 个卡片排查</span>
|
||||
</div>
|
||||
<div className="diag-panel-actions">
|
||||
<button className="secondary-btn" type="button" onClick={() => setShowCardDiagnostics(prev => !prev)}>
|
||||
{showCardDiagnostics ? '收起日志' : '查看日志'}
|
||||
</button>
|
||||
{showCardDiagnostics && (
|
||||
<>
|
||||
<button
|
||||
className="secondary-btn"
|
||||
type="button"
|
||||
onClick={() => void fetchExportCardDiagnosticsSnapshot(1600)}
|
||||
disabled={isExportCardDiagSyncing}
|
||||
>
|
||||
<RefreshCw size={14} className={isExportCardDiagSyncing ? 'spin' : ''} />
|
||||
刷新
|
||||
</button>
|
||||
<button className="secondary-btn" type="button" onClick={() => void clearCardDiagnostics()}>
|
||||
清空
|
||||
</button>
|
||||
<button className="secondary-btn" type="button" onClick={() => void exportCardDiagnosticsLogs()}>
|
||||
<Download size={14} />
|
||||
导出日志
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCardDiagnostics && (
|
||||
<>
|
||||
<div className="diag-overview-grid">
|
||||
<div className="diag-overview-item">
|
||||
<span>日志总数</span>
|
||||
<strong>{backendDiagSnapshot.summary.totalLogs + frontendDiagLogs.length}</strong>
|
||||
</div>
|
||||
<div className="diag-overview-item">
|
||||
<span>活跃步骤</span>
|
||||
<strong>{backendDiagSnapshot.activeSteps.length}</strong>
|
||||
</div>
|
||||
<div className="diag-overview-item">
|
||||
<span>当前运行步骤</span>
|
||||
<strong>{cardDiagRunningStepCount}</strong>
|
||||
</div>
|
||||
<div className="diag-overview-item">
|
||||
<span>疑似卡住</span>
|
||||
<strong className={cardDiagStalledStepCount > 0 ? 'warn' : ''}>{cardDiagStalledStepCount}</strong>
|
||||
</div>
|
||||
<div className="diag-overview-item">
|
||||
<span>最近告警</span>
|
||||
<strong>{backendDiagSnapshot.summary.warnCount}</strong>
|
||||
</div>
|
||||
<div className="diag-overview-item">
|
||||
<span>最近错误</span>
|
||||
<strong className={backendDiagSnapshot.summary.errorCount > 0 ? 'warn' : ''}>{backendDiagSnapshot.summary.errorCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="diag-step-chain">
|
||||
<div className="diag-step-chain-title">
|
||||
当前链路
|
||||
{latestCardDiagTraceId ? ` · trace=${latestCardDiagTraceId}` : ''}
|
||||
</div>
|
||||
{cardDiagTraceSteps.length === 0 ? (
|
||||
<div className="diag-empty">暂无链路步骤,请先触发一次卡片统计。</div>
|
||||
) : (
|
||||
<div className="diag-step-list">
|
||||
{cardDiagTraceSteps.map((step, index) => (
|
||||
<div key={`${step.stepId}-${index}`} className={`diag-step-item ${step.status} ${step.stalled ? 'stalled' : ''}`}>
|
||||
<span className="diag-step-order">{index + 1}</span>
|
||||
<div className="diag-step-main">
|
||||
<div className="diag-step-name">{step.stepName}</div>
|
||||
<div className="diag-step-meta">
|
||||
<span>{step.source}</span>
|
||||
<span>{step.status}</span>
|
||||
<span>耗时 {step.durationMs ?? Math.max(0, Date.now() - step.startedAt)}ms</span>
|
||||
{step.stalled && <span className="warn">卡住 {Math.max(0, Date.now() - step.lastUpdatedAt)}ms</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="diag-log-toolbar">
|
||||
{([
|
||||
{ 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 => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
className={`diag-filter-btn ${diagFilter === item.value ? 'active' : ''}`}
|
||||
onClick={() => setDiagFilter(item.value)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="diag-log-list">
|
||||
{filteredCardDiagLogs.length === 0 ? (
|
||||
<div className="diag-empty">暂无日志</div>
|
||||
) : (
|
||||
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 (
|
||||
<div key={`${log.id}-${timeLabel}`} className={`diag-log-item ${log.level}`}>
|
||||
<div className="diag-log-top">
|
||||
<span className="diag-log-time">{timeLabel}</span>
|
||||
<span className="diag-log-tag">{log.source}</span>
|
||||
<span className={`diag-log-tag ${log.level}`}>{log.level}</span>
|
||||
{log.status && <span className={`diag-log-tag ${log.status}`}>{log.status}</span>}
|
||||
{typeof log.durationMs === 'number' && <span className="diag-log-tag">耗时 {log.durationMs}ms</span>}
|
||||
</div>
|
||||
<div className="diag-log-message">{log.message}</div>
|
||||
{(log.stepName || log.traceId) && (
|
||||
<div className="diag-log-meta">
|
||||
{log.stepName && <span>{log.stepName}</span>}
|
||||
{log.traceId && <span>trace={log.traceId}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="session-table-section">
|
||||
<div className="table-toolbar">
|
||||
<div className="table-tabs" role="tablist" aria-label="会话类型">
|
||||
|
||||
50
src/types/electron.d.ts
vendored
50
src/types/electron.d.ts
vendored
@@ -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<string, unknown>
|
||||
}>
|
||||
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<WxidInfo[]>
|
||||
@@ -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
|
||||
}>
|
||||
|
||||
Reference in New Issue
Block a user