diff --git a/electron/preload.ts b/electron/preload.ts index b8d6aa1..40f5acc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -276,7 +276,7 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('export:pauseTask', taskId), stopTask: (taskId: string) => ipcRenderer.invoke('export:stopTask', taskId), - onProgress: (callback: (payload: { current: number; total: number; currentSession: string; phase: string }) => void) => { + onProgress: (callback: (payload: { current: number; total: number; currentSession: string; currentSessionId?: string; phase: string }) => void) => { ipcRenderer.on('export:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('export:progress') } diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index b91d289..3622651 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -108,6 +108,7 @@ export interface ExportProgress { current: number total: number currentSession: string + currentSessionId?: string phase: 'preparing' | 'exporting' | 'exporting-media' | 'exporting-voice' | 'writing' | 'complete' phaseProgress?: number phaseTotal?: number @@ -5299,7 +5300,8 @@ class ExportService { ...progress, current: completedCount, total: sessionIds.length, - currentSession: sessionInfo.displayName + currentSession: sessionInfo.displayName, + currentSessionId: sessionId }) } @@ -5411,6 +5413,7 @@ class ExportService { current: sessionIds.length, total: sessionIds.length, currentSession: '', + currentSessionId: '', phase: 'complete' }) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index cb66dc9..b266431 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -557,6 +557,88 @@ color: var(--text-secondary); } +.task-perf-summary { + margin-top: 6px; + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-panel { + margin-top: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 8px; + background: var(--bg-secondary); + display: grid; + gap: 8px; +} + +.task-perf-title { + font-size: 12px; + color: var(--text-primary); + font-weight: 600; +} + +.task-perf-row { + display: grid; + gap: 4px; +} + +.task-perf-row-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-row-track { + height: 6px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.08); + overflow: hidden; +} + +.task-perf-row-fill { + height: 100%; + background: var(--primary); +} + +.task-perf-empty { + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-session-list { + display: grid; + gap: 4px; +} + +.task-perf-session-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + font-size: 11px; + color: var(--text-secondary); +} + +.task-perf-session-rank { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-perf-session-time { + font-variant-numeric: tabular-nums; +} + .task-error { margin-top: 6px; font-size: 12px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 17e3352..4f71555 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -79,11 +79,29 @@ interface TaskProgress { current: number total: number currentName: string + phase: ExportProgress['phase'] | '' phaseLabel: string phaseProgress: number phaseTotal: number } +type TaskPerfStage = 'collect' | 'build' | 'write' | 'other' + +interface TaskSessionPerformance { + sessionId: string + sessionName: string + startedAt: number + finishedAt?: number + elapsedMs: number + lastPhase?: ExportProgress['phase'] + lastPhaseStartedAt?: number +} + +interface TaskPerformance { + stages: Record + sessions: Record +} + interface ExportTaskPayload { sessionIds: string[] outputDir: string @@ -110,6 +128,7 @@ interface ExportTask { error?: string payload: ExportTaskPayload progress: TaskProgress + performance?: TaskPerformance } interface ExportDialogState { @@ -170,11 +189,165 @@ const createEmptyProgress = (): TaskProgress => ({ current: 0, total: 0, currentName: '', + phase: '', phaseLabel: '', phaseProgress: 0, phaseTotal: 0 }) +const createEmptyTaskPerformance = (): TaskPerformance => ({ + stages: { + collect: 0, + build: 0, + write: 0, + other: 0 + }, + sessions: {} +}) + +const isTextBatchTask = (task: ExportTask): boolean => ( + task.payload.scope === 'content' && task.payload.contentType === 'text' +) + +const resolvePerfStageByPhase = (phase?: ExportProgress['phase']): TaskPerfStage => { + if (phase === 'preparing') return 'collect' + if (phase === 'writing') return 'write' + if (phase === 'exporting' || phase === 'exporting-media' || phase === 'exporting-voice') return 'build' + return 'other' +} + +const cloneTaskPerformance = (performance?: TaskPerformance): TaskPerformance => ({ + stages: { + collect: performance?.stages.collect || 0, + build: performance?.stages.build || 0, + write: performance?.stages.write || 0, + other: performance?.stages.other || 0 + }, + sessions: Object.fromEntries( + Object.entries(performance?.sessions || {}).map(([sessionId, session]) => [sessionId, { ...session }]) + ) +}) + +const resolveTaskSessionName = (task: ExportTask, sessionId: string, fallback?: string): string => { + const idx = task.payload.sessionIds.indexOf(sessionId) + if (idx >= 0) { + return task.payload.sessionNames[idx] || fallback || sessionId + } + return fallback || sessionId +} + +const applyProgressToTaskPerformance = ( + task: ExportTask, + payload: ExportProgress, + now: number +): TaskPerformance | undefined => { + if (!isTextBatchTask(task)) return task.performance + const sessionId = String(payload.currentSessionId || '').trim() + if (!sessionId) return task.performance || createEmptyTaskPerformance() + + const performance = cloneTaskPerformance(task.performance) + const sessionName = resolveTaskSessionName(task, sessionId, payload.currentSession || sessionId) + const existing = performance.sessions[sessionId] + const session: TaskSessionPerformance = existing + ? { ...existing, sessionName: existing.sessionName || sessionName } + : { + sessionId, + sessionName, + startedAt: now, + elapsedMs: 0 + } + + if (!session.finishedAt && session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { + const delta = Math.max(0, now - session.lastPhaseStartedAt) + performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta + } + + session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt) + + if (payload.phase === 'complete') { + session.finishedAt = now + session.lastPhase = undefined + session.lastPhaseStartedAt = undefined + } else { + session.lastPhase = payload.phase + session.lastPhaseStartedAt = now + } + + performance.sessions[sessionId] = session + return performance +} + +const finalizeTaskPerformance = (task: ExportTask, now: number): TaskPerformance | undefined => { + if (!isTextBatchTask(task) || !task.performance) return task.performance + const performance = cloneTaskPerformance(task.performance) + for (const session of Object.values(performance.sessions)) { + if (session.finishedAt) continue + if (session.lastPhase && typeof session.lastPhaseStartedAt === 'number') { + const delta = Math.max(0, now - session.lastPhaseStartedAt) + performance.stages[resolvePerfStageByPhase(session.lastPhase)] += delta + } + session.elapsedMs = Math.max(session.elapsedMs, now - session.startedAt) + session.finishedAt = now + session.lastPhase = undefined + session.lastPhaseStartedAt = undefined + } + return performance +} + +const getTaskPerformanceStageTotals = ( + performance: TaskPerformance | undefined, + now: number +): Record => { + const totals: Record = { + collect: performance?.stages.collect || 0, + build: performance?.stages.build || 0, + write: performance?.stages.write || 0, + other: performance?.stages.other || 0 + } + if (!performance) return totals + for (const session of Object.values(performance.sessions)) { + if (session.finishedAt) continue + if (!session.lastPhase || typeof session.lastPhaseStartedAt !== 'number') continue + const delta = Math.max(0, now - session.lastPhaseStartedAt) + totals[resolvePerfStageByPhase(session.lastPhase)] += delta + } + return totals +} + +const getTaskPerformanceTopSessions = ( + performance: TaskPerformance | undefined, + now: number, + limit = 5 +): Array => { + if (!performance) return [] + return Object.values(performance.sessions) + .map((session) => { + const liveElapsedMs = session.finishedAt + ? session.elapsedMs + : Math.max(session.elapsedMs, now - session.startedAt) + return { + ...session, + liveElapsedMs + } + }) + .sort((a, b) => b.liveElapsedMs - a.liveElapsedMs) + .slice(0, limit) +} + +const formatDurationMs = (ms: number): string => { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + const seconds = totalSeconds % 60 + if (hours > 0) { + return `${hours}小时${minutes}分${seconds}秒` + } + if (minutes > 0) { + return `${minutes}分${seconds}秒` + } + return `${seconds}秒` +} + const getTaskStatusLabel = (task: ExportTask): string => { if (task.status === 'queued') return '排队中' if (task.status === 'running') { @@ -570,6 +743,7 @@ function ExportPage() { const [isSnsStatsLoading, setIsSnsStatsLoading] = useState(true) const [isBaseConfigLoading, setIsBaseConfigLoading] = useState(true) const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false) + const [expandedPerfTaskId, setExpandedPerfTaskId] = useState(null) const [sessions, setSessions] = useState([]) const [sessionDataSource, setSessionDataSource] = useState(null) const [sessionContactsUpdatedAt, setSessionContactsUpdatedAt] = useState(null) @@ -1022,6 +1196,14 @@ function ExportPage() { tasksRef.current = tasks }, [tasks]) + useEffect(() => { + if (!expandedPerfTaskId) return + const target = tasks.find(task => task.id === expandedPerfTaskId) + if (!target || !isTextBatchTask(target)) { + setExpandedPerfTaskId(null) + } + }, [tasks, expandedPerfTaskId]) + useEffect(() => { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) @@ -1044,6 +1226,14 @@ function ExportPage() { return () => clearInterval(timer) }, [isExportRoute]) + useEffect(() => { + if (!isTaskCenterOpen || !expandedPerfTaskId) return + const target = tasks.find(task => task.id === expandedPerfTaskId) + if (!target || target.status !== 'running' || !isTextBatchTask(target)) return + const timer = window.setInterval(() => setNowTick(Date.now()), 1000) + return () => window.clearInterval(timer) + }, [isTaskCenterOpen, expandedPerfTaskId, tasks]) + const loadBaseConfig = useCallback(async (): Promise => { setIsBaseConfigLoading(true) let isReady = true @@ -1718,7 +1908,10 @@ function ExportPage() { controlState: undefined, startedAt: Date.now(), finishedAt: undefined, - error: undefined + error: undefined, + performance: isTextBatchTask(task) + ? (task.performance || createEmptyTaskPerformance()) + : task.performance })) progressUnsubscribeRef.current?.() @@ -1732,6 +1925,7 @@ function ExportPage() { current: payload.current || 0, total: payload.total || 0, currentName: '', + phase: 'exporting', phaseLabel: payload.status || '', phaseProgress: payload.total > 0 ? payload.current : 0, phaseTotal: payload.total || 0 @@ -1743,16 +1937,20 @@ function ExportPage() { progressUnsubscribeRef.current = window.electronAPI.export.onProgress((payload: ExportProgress) => { updateTask(next.id, task => { if (task.status !== 'running') return task + const now = Date.now() + const performance = applyProgressToTaskPerformance(task, payload, now) return { ...task, progress: { current: payload.current, total: payload.total, currentName: payload.currentSession, + phase: payload.phase, phaseLabel: payload.phaseLabel || '', phaseProgress: payload.phaseProgress || 0, phaseTotal: payload.phaseTotal || 0 - } + }, + performance } }) }) @@ -1776,7 +1974,8 @@ function ExportPage() { status: 'error', controlState: undefined, finishedAt: Date.now(), - error: result.error || '朋友圈导出失败' + error: result.error || '朋友圈导出失败', + performance: finalizeTaskPerformance(task, Date.now()) })) } else if (result.stopped) { updateTask(next.id, task => ({ @@ -1787,7 +1986,8 @@ function ExportPage() { progress: { ...task.progress, phaseLabel: '已停止' - } + }, + performance: finalizeTaskPerformance(task, Date.now()) })) } else if (result.paused) { updateTask(next.id, task => ({ @@ -1798,7 +1998,8 @@ function ExportPage() { progress: { ...task.progress, phaseLabel: '已暂停' - } + }, + performance: finalizeTaskPerformance(task, Date.now()) })) } else { const doneAt = Date.now() @@ -1820,7 +2021,8 @@ function ExportPage() { phaseLabel: '完成', phaseProgress: 1, phaseTotal: 1 - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) } } else { @@ -1841,7 +2043,8 @@ function ExportPage() { status: 'error', controlState: undefined, finishedAt: Date.now(), - error: result.error || '导出失败' + error: result.error || '导出失败', + performance: finalizeTaskPerformance(task, Date.now()) })) } else { const doneAt = Date.now() @@ -1867,7 +2070,8 @@ function ExportPage() { current: result.successCount + result.failCount, total: task.progress.total || next.payload.sessionIds.length, phaseLabel: '已停止' - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) } else if (result.paused) { const pendingSessionIds = Array.isArray(result.pendingSessionIds) @@ -1892,7 +2096,8 @@ function ExportPage() { phaseLabel: '完成', phaseProgress: 1, phaseTotal: 1 - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) } else { updateTask(next.id, task => ({ @@ -1910,7 +2115,8 @@ function ExportPage() { current: result.successCount + result.failCount, total: task.progress.total || next.payload.sessionIds.length, phaseLabel: '已暂停' - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) } } else { @@ -1926,18 +2132,21 @@ function ExportPage() { phaseLabel: '完成', phaseProgress: 1, phaseTotal: 1 - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) } } } } catch (error) { + const doneAt = Date.now() updateTask(next.id, task => ({ ...task, status: 'error', controlState: undefined, - finishedAt: Date.now(), - error: String(error) + finishedAt: doneAt, + error: String(error), + performance: finalizeTaskPerformance(task, doneAt) })) } finally { progressUnsubscribeRef.current?.() @@ -1966,7 +2175,8 @@ function ExportPage() { updateTask(taskId, task => ({ ...task, status: 'paused', - controlState: undefined + controlState: undefined, + performance: finalizeTaskPerformance(task, Date.now()) })) return } @@ -2008,15 +2218,17 @@ function ExportPage() { if (!shouldStop) return if (target.status === 'queued' || target.status === 'paused') { + const doneAt = Date.now() updateTask(taskId, task => ({ ...task, status: 'stopped', controlState: undefined, - finishedAt: Date.now(), + finishedAt: doneAt, progress: { ...task.progress, phaseLabel: '已停止' - } + }, + performance: finalizeTaskPerformance(task, doneAt) })) return } @@ -2073,7 +2285,10 @@ function ExportPage() { contentType: exportDialog.contentType, snsOptions }, - progress: createEmptyProgress() + progress: createEmptyProgress(), + performance: exportDialog.scope === 'content' && exportDialog.contentType === 'text' + ? createEmptyTaskPerformance() + : undefined } setTasks(prev => [task, ...prev]) @@ -2798,7 +3013,10 @@ function ExportPage() { {isTaskCenterOpen && (
setIsTaskCenterOpen(false)} + onClick={() => { + setIsTaskCenterOpen(false) + setExpandedPerfTaskId(null) + }} >
setIsTaskCenterOpen(false)} + onClick={() => { + setIsTaskCenterOpen(false) + setExpandedPerfTaskId(null) + }} aria-label="关闭任务中心" > @@ -2826,68 +3047,139 @@ function ExportPage() {
暂无任务。点击会话导出或卡片导出后会在这里创建任务。
) : (
- {tasks.map(task => ( -
-
-
{task.title}
-
- {getTaskStatusLabel(task)} - {new Date(task.createdAt).toLocaleString('zh-CN')} + {tasks.map(task => { + const canShowPerfDetail = isTextBatchTask(task) && Boolean(task.performance) + const isPerfExpanded = expandedPerfTaskId === task.id + const stageTotals = canShowPerfDetail + ? getTaskPerformanceStageTotals(task.performance, nowTick) + : null + const stageTotalMs = stageTotals + ? stageTotals.collect + stageTotals.build + stageTotals.write + stageTotals.other + : 0 + const topSessions = isPerfExpanded + ? getTaskPerformanceTopSessions(task.performance, nowTick, 5) + : [] + return ( +
+
+
{task.title}
+
+ {getTaskStatusLabel(task)} + {new Date(task.createdAt).toLocaleString('zh-CN')} +
+ {(task.status === 'running' || task.status === 'paused') && ( + <> +
+
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} + /> +
+
+ {task.progress.total > 0 + ? `${task.progress.current} / ${task.progress.total}` + : '处理中'} + {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} +
+ + )} + {canShowPerfDetail && stageTotals && ( +
+ 累计耗时 {formatDurationMs(stageTotalMs)} + {task.progress.total > 0 && ( + 平均/会话 {formatDurationMs(Math.floor(stageTotalMs / Math.max(1, task.progress.total)))} + )} +
+ )} + {canShowPerfDetail && isPerfExpanded && stageTotals && ( +
+
阶段耗时分布
+ {[ + { key: 'collect' as const, label: '收集消息' }, + { key: 'build' as const, label: '构建消息' }, + { key: 'write' as const, label: '写入文件' }, + { key: 'other' as const, label: '其他' } + ].map(item => { + const value = stageTotals[item.key] + const ratio = stageTotalMs > 0 ? Math.min(100, (value / stageTotalMs) * 100) : 0 + return ( +
+
+ {item.label} + {formatDurationMs(value)} +
+
+
+
+
+ ) + })} +
最慢会话 Top5
+ {topSessions.length === 0 ? ( +
暂无会话耗时数据
+ ) : ( +
+ {topSessions.map((session, index) => ( +
+ + {index + 1}. {session.sessionName || session.sessionId} + {!session.finishedAt ? '(进行中)' : ''} + + {formatDurationMs(session.liveElapsedMs)} +
+ ))} +
+ )} +
+ )} + {task.status === 'error' &&
{task.error || '任务失败'}
} +
+
+ {canShowPerfDetail && ( + + )} + {(task.status === 'running' || task.status === 'queued') && ( + + )} + {task.status === 'paused' && ( + + )} + {(task.status === 'running' || task.status === 'queued' || task.status === 'paused') && ( + + )} +
- {(task.status === 'running' || task.status === 'paused') && ( - <> -
-
0 ? (task.progress.current / task.progress.total) * 100 : 0}%` }} - /> -
-
- {task.progress.total > 0 - ? `${task.progress.current} / ${task.progress.total}` - : '处理中'} - {task.progress.phaseLabel ? ` · ${task.progress.phaseLabel}` : ''} -
- - )} - {task.status === 'error' &&
{task.error || '任务失败'}
}
-
- {(task.status === 'running' || task.status === 'queued') && ( - - )} - {task.status === 'paused' && ( - - )} - {(task.status === 'running' || task.status === 'queued' || task.status === 'paused') && ( - - )} - -
-
- ))} + ) + })}
)}