feat(export): add card stats diagnostics panel and log export

This commit is contained in:
tisonhuang
2026-03-03 10:05:23 +08:00
parent e9971aa6c4
commit 84b54e43aa
7 changed files with 1750 additions and 73 deletions

View File

@@ -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;
}

View File

@@ -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="会话类型">

View File

@@ -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
}>