diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 1557679..977f183 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -4,6 +4,12 @@ import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, import ReactECharts from 'echarts-for-react' import { useAnalyticsStore } from '../stores/analyticsStore' import { useThemeStore } from '../stores/themeStore' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnalyticsPage.scss' import { Avatar } from '../components/Avatar' @@ -48,6 +54,13 @@ function AnalyticsPage() { const loadData = useCallback(async (forceRefresh = false) => { if (isLoaded && !forceRefresh) return + const taskId = registerBackgroundTask({ + sourcePage: 'analytics', + title: forceRefresh ? '刷新分析看板' : '加载分析看板', + detail: '准备读取整体统计数据', + progressText: '整体统计', + cancelable: true + }) setIsLoading(true) setError(null) setProgress(0) @@ -60,27 +73,70 @@ function AnalyticsPage() { try { setLoadingStatus('正在统计消息数据...') + updateBackgroundTask(taskId, { + detail: '正在统计消息数据', + progressText: '整体统计' + }) const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前页面分析流程已结束' + }) + setIsLoading(false) + return + } if (statsResult.success && statsResult.data) { setStatistics(statsResult.data) } else { setError(statsResult.error || '加载统计数据失败') + finishBackgroundTask(taskId, 'failed', { + detail: statsResult.error || '加载统计数据失败' + }) setIsLoading(false) return } setLoadingStatus('正在分析联系人排名...') + updateBackgroundTask(taskId, { + detail: '正在分析联系人排名', + progressText: '联系人排名' + }) const rankingsResult = await window.electronAPI.analytics.getContactRankings(20) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,联系人排名后续步骤未继续' + }) + setIsLoading(false) + return + } if (rankingsResult.success && rankingsResult.data) { setRankings(rankingsResult.data) } setLoadingStatus('正在计算时间分布...') + updateBackgroundTask(taskId, { + detail: '正在计算时间分布', + progressText: '时间分布' + }) const timeResult = await window.electronAPI.analytics.getTimeDistribution() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,时间分布结果未继续写入' + }) + setIsLoading(false) + return + } if (timeResult.success && timeResult.data) { setTimeDistribution(timeResult.data) } markLoaded() + finishBackgroundTask(taskId, 'completed', { + detail: '分析看板数据加载完成', + progressText: '已完成' + }) } catch (e) { setError(String(e)) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) if (removeListener) removeListener() diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index ded4362..018fbdb 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -1,6 +1,12 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnnualReportPage.scss' type YearOption = number | 'all' @@ -49,8 +55,17 @@ function AnnualReportPage() { useEffect(() => { let disposed = false let taskId = '' + let uiTaskId = '' const applyLoadPayload = (payload: YearsLoadPayload) => { + if (uiTaskId) { + updateBackgroundTask(uiTaskId, { + detail: payload.statusText || '正在加载可用年份', + progressText: payload.done + ? '已完成' + : `${Array.isArray(payload.years) ? payload.years.length : 0} 个年份` + }) + } if (payload.strategy) setLoadStrategy(payload.strategy) if (payload.phase) setLoadPhase(payload.phase) if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText) @@ -91,6 +106,14 @@ function AnnualReportPage() { setIsLoadingMoreYears(false) setHasYearsLoadFinished(true) setLoadPhase('done') + if (uiTaskId) { + finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', { + detail: payload.canceled + ? '年度报告年份加载已停止' + : `年度报告年份加载完成,共 ${years.length} 个年份`, + progressText: payload.canceled ? '已停止' : `${years.length} 个年份` + }) + } } else { setIsLoadingMoreYears(true) setHasYearsLoadFinished(false) @@ -105,6 +128,18 @@ function AnnualReportPage() { }) const startLoad = async () => { + uiTaskId = registerBackgroundTask({ + sourcePage: 'annualReport', + title: '年度报告年份加载', + detail: '准备使用原生快速模式加载年份', + progressText: '初始化', + cancelable: true, + onCancel: async () => { + if (taskId) { + await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId) + } + } + }) setIsLoading(true) setIsLoadingMoreYears(true) setHasYearsLoadFinished(false) @@ -120,6 +155,9 @@ function AnnualReportPage() { try { const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad() if (!startResult.success || !startResult.taskId) { + finishBackgroundTask(uiTaskId, 'failed', { + detail: startResult.error || '加载年度数据失败' + }) setLoadError(startResult.error || '加载年度数据失败') setIsLoading(false) setIsLoadingMoreYears(false) @@ -131,6 +169,9 @@ function AnnualReportPage() { } } catch (e) { console.error(e) + finishBackgroundTask(uiTaskId, 'failed', { + detail: String(e) + }) setLoadError(String(e)) setIsLoading(false) setIsLoadingMoreYears(false) diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index 344393b..94d82f5 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' import html2canvas from 'html2canvas' import { useThemeStore } from '../stores/themeStore' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './AnnualReportWindow.scss' // SVG 背景图案 (用于导出) @@ -164,6 +170,13 @@ function AnnualReportWindow() { }, []) const generateReport = async (year: number) => { + const taskId = registerBackgroundTask({ + sourcePage: 'annualReport', + title: '年度报告生成', + detail: `正在生成 ${formatYearLabel(year)} 年度报告`, + progressText: '初始化', + cancelable: true + }) setIsLoading(true) setError(null) setLoadingProgress(0) @@ -171,25 +184,46 @@ function AnnualReportWindow() { const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => { setLoadingProgress(payload.progress) setLoadingStage(payload.status) + updateBackgroundTask(taskId, { + detail: payload.status || '正在生成年度报告', + progressText: `${Math.max(0, Math.round(payload.progress || 0))}%` + }) }) try { const result = await window.electronAPI.annualReport.generateReport(year) removeProgressListener?.() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前报告结果未继续写入页面' + }) + setIsLoading(false) + return + } setLoadingProgress(100) setLoadingStage('完成') if (result.success && result.data) { + finishBackgroundTask(taskId, 'completed', { + detail: '年度报告生成完成', + progressText: '100%' + }) setTimeout(() => { setReportData(result.data!) setIsLoading(false) }, 300) } else { + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '生成年度报告失败' + }) setError(result.error || '生成报告失败') setIsLoading(false) } } catch (e) { removeProgressListener?.() + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) setError(String(e)) setIsLoading(false) } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index a663b67..6256d9b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import { emitOpenSingleExport, onExportSessionStatus, @@ -1067,6 +1073,13 @@ function ChatPage(props: ChatPageProps) { const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页会话详情统计', + detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`, + progressText: '基础信息', + cancelable: true + }) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId) @@ -1130,8 +1143,23 @@ function ChatPage(props: ChatPageProps) { } try { + updateBackgroundTask(taskId, { + detail: '正在读取会话基础详情', + progressText: '基础信息' + }) const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前基础查询结束后未继续补充统计' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧详情任务已停止' + }) + return + } if (result.success && result.detail) { setSessionDetail((prev) => ({ wxid: normalizedSessionId, @@ -1170,6 +1198,10 @@ function ChatPage(props: ChatPageProps) { } try { + updateBackgroundTask(taskId, { + detail: '正在读取补充信息与导出统计', + progressText: '补充统计' + }) const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( @@ -1178,7 +1210,18 @@ function ChatPage(props: ChatPageProps) { ) ]) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,补充统计结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧补充统计任务已停止' + }) + return + } if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { const detail = extraResultSettled.value.detail @@ -1214,8 +1257,15 @@ function ChatPage(props: ChatPageProps) { }) } } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页会话详情统计完成', + progressText: '已完成' + }) } catch (e) { console.error('加载会话详情补充统计失败:', e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingDetailExtra(false) @@ -1228,13 +1278,31 @@ function ChatPage(props: ChatPageProps) { if (!normalizedSessionId || isLoadingRelationStats) return const requestSeq = detailRequestSeqRef.current + const taskId = registerBackgroundTask({ + sourcePage: 'chat', + title: '聊天页关系统计补算', + detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`, + progressText: '关系统计', + cancelable: true + }) setIsLoadingRelationStats(true) try { const relationResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前关系统计查询结束后未继续刷新' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计任务已停止' + }) + return + } const metric = relationResult.success && relationResult.data ? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined @@ -1254,11 +1322,26 @@ function ChatPage(props: ChatPageProps) { setIsRefreshingDetailStats(true) void (async () => { try { + updateBackgroundTask(taskId, { + detail: '正在刷新关系统计结果', + progressText: '关系统计刷新' + }) const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } ) - if (requestSeq !== detailRequestSeqRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,刷新结果未继续写入' + }) + return + } + if (requestSeq !== detailRequestSeqRef.current) { + finishBackgroundTask(taskId, 'canceled', { + detail: '会话已切换,旧关系统计刷新任务已停止' + }) + return + } if (freshResult.success && freshResult.data) { const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined @@ -1266,17 +1349,32 @@ function ChatPage(props: ChatPageProps) { applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) } } + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) } catch (error) { console.error('刷新会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsRefreshingDetailStats(false) } } })() + } else { + finishBackgroundTask(taskId, 'completed', { + detail: '聊天页关系统计补算完成', + progressText: '已完成' + }) } } catch (error) { console.error('加载会话关系统计失败:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingRelationStats(false) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index d9c66df..6c2b112 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -254,6 +254,168 @@ } } +.session-load-detail-summary { + padding: 12px 12px 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.session-load-detail-summary-text { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 18px; + color: var(--text-primary); + } + + em { + font-style: normal; + color: var(--text-tertiary); + } +} + +.session-load-detail-note { + margin: 8px 12px 0; + font-size: 12px; + line-height: 1.6; + color: var(--text-tertiary); +} + +.session-load-detail-stop-btn, +.session-load-detail-task-stop-btn { + border: 1px solid color-mix(in srgb, var(--danger, #ef4444) 45%, var(--border-color)); + border-radius: 8px; + background: color-mix(in srgb, var(--danger, #ef4444) 8%, var(--bg-secondary)); + color: color-mix(in srgb, var(--danger, #ef4444) 85%, var(--text-primary)); + cursor: pointer; + white-space: nowrap; + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } +} + +.session-load-detail-stop-btn { + padding: 8px 12px; + font-size: 12px; +} + +.session-load-detail-task-list { + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.session-load-detail-task-item { + border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-primary) 86%, var(--bg-secondary)); + padding: 10px 12px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + &.status-cancel_requested { + border-color: color-mix(in srgb, var(--warning, #f59e0b) 36%, var(--border-color)); + } + + &.status-failed { + border-color: color-mix(in srgb, var(--danger, #ef4444) 36%, var(--border-color)); + } +} + +.session-load-detail-task-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + + p { + margin: 0; + font-size: 12px; + line-height: 1.55; + color: var(--text-secondary); + } +} + +.session-load-detail-task-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); + + strong { + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-task-source { + padding: 2px 8px; + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-secondary); +} + +.session-load-detail-task-status { + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--bg-secondary) 80%, transparent); + color: var(--text-secondary); + + &.status-running { + color: var(--primary); + background: rgba(var(--primary-rgb), 0.1); + } + + &.status-cancel_requested { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + &.status-completed { + color: #166534; + background: rgba(34, 197, 94, 0.14); + } + + &.status-failed { + color: #b91c1c; + background: rgba(239, 68, 68, 0.14); + } +} + +.session-load-detail-task-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 11px; + color: var(--text-tertiary); +} + +.session-load-detail-task-stop-btn { + padding: 7px 10px; + font-size: 12px; + flex-shrink: 0; +} + +.session-load-detail-empty { + padding: 12px; + font-size: 12px; + color: var(--text-tertiary); +} + .session-load-detail-table { display: flex; flex-direction: column; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 7a8dc3c..956c4f2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -30,6 +30,7 @@ import { } from 'lucide-react' import type { ChatSession as AppChatSession, ContactInfo } from '../types/models' import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron' +import type { BackgroundTaskRecord } from '../types/backgroundTask' import * as configService from '../services/config' import { emitExportSessionStatus, @@ -37,6 +38,11 @@ import { onExportSessionStatusRequest, onOpenSingleExport } from '../services/exportBridge' +import { + requestCancelBackgroundTask, + requestCancelBackgroundTasks, + subscribeBackgroundTasks +} from '../services/backgroundTaskMonitor' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog' @@ -176,6 +182,24 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const backgroundTaskSourceLabels: Record = { + export: '导出页', + chat: '聊天页', + analytics: '分析页', + sns: '朋友圈页', + groupAnalytics: '群分析页', + annualReport: '年度报告', + other: '其他页面' +} + +const backgroundTaskStatusLabels: Record = { + running: '运行中', + cancel_requested: '停止中', + completed: '已完成', + failed: '失败', + canceled: '已停止' +} + const conversationTabLabels: Record = { private: '私聊', group: '群聊', @@ -1422,6 +1446,7 @@ function ExportPage() { const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState>({}) const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState(null) const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('') + const [backgroundTasks, setBackgroundTasks] = useState([]) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1911,6 +1936,10 @@ function ExportPage() { return () => window.clearInterval(timer) }, [contactsList.length, isContactsListLoading, contactsLoadIssue]) + useEffect(() => { + return subscribeBackgroundTasks(setBackgroundTasks) + }, []) + useEffect(() => { tasksRef.current = tasks }, [tasks]) @@ -5499,6 +5528,16 @@ function ExportPage() { alert('复制失败,请手动复制诊断信息') } }, [contactsDiagnosticsText]) + const handleCancelBackgroundTask = useCallback((taskId: string) => { + requestCancelBackgroundTask(taskId) + }, []) + const handleCancelAllNonExportTasks = useCallback(() => { + requestCancelBackgroundTasks(task => ( + task.sourcePage !== 'export' && + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )) + }, []) const sessionContactsUpdatedAtLabel = useMemo(() => { if (!sessionContactsUpdatedAt) return '' @@ -5586,6 +5625,21 @@ function ExportPage() { const contactsBottomScrollbarInnerStyle = useMemo(() => ({ width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px` }), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth]) + const nonExportBackgroundTasks = useMemo(() => ( + backgroundTasks.filter(task => task.sourcePage !== 'export') + ), [backgroundTasks]) + const runningNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => task.status === 'running' || task.status === 'cancel_requested').length + ), [nonExportBackgroundTasks]) + const cancelableNonExportTaskCount = useMemo(() => ( + nonExportBackgroundTasks.filter(task => ( + task.cancelable && + (task.status === 'running' || task.status === 'cancel_requested') + )).length + ), [nonExportBackgroundTasks]) + const nonExportBackgroundTasksUpdatedAt = useMemo(() => ( + nonExportBackgroundTasks.reduce((latest, task) => Math.max(latest, task.updatedAt || 0), 0) + ), [nonExportBackgroundTasks]) const sessionLoadDetailUpdatedAt = useMemo(() => { let latest = 0 for (const row of sessionLoadDetailRows) { @@ -6422,6 +6476,67 @@ function ExportPage() {
+
+
其他页面后台任务
+
+
+ {runningNonExportTaskCount} + 个任务正在占用后台读取资源 + {nonExportBackgroundTasksUpdatedAt > 0 && ( + 最近更新时间 {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })} + )} +
+ +
+

+ 停止请求会阻止其他页面继续发起后续统计或补算;当前已经发出的单次查询,会在返回后结束。 +

+ {nonExportBackgroundTasks.length > 0 ? ( +
+ {nonExportBackgroundTasks.map((task) => ( +
+
+
+ + {backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other} + + {task.title} + + {backgroundTaskStatusLabels[task.status]} + +
+

{task.detail || '暂无详细说明'}

+
+ 开始:{formatLoadDetailTime(task.startedAt)} + 更新:{formatLoadDetailTime(task.updatedAt)} + {task.progressText && 进度:{task.progressText}} +
+
+ +
+ ))} +
+ ) : ( +
+ 当前没有检测到其他页面后台任务 +
+ )} +
+
总消息数
diff --git a/src/pages/GroupAnalyticsPage.tsx b/src/pages/GroupAnalyticsPage.tsx index 05a616d..4d2c65e 100644 --- a/src/pages/GroupAnalyticsPage.tsx +++ b/src/pages/GroupAnalyticsPage.tsx @@ -5,6 +5,12 @@ import { Avatar } from '../components/Avatar' import ReactECharts from 'echarts-for-react' import DateRangePicker from '../components/DateRangePicker' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import './GroupAnalyticsPage.scss' interface GroupChatInfo { @@ -176,15 +182,39 @@ function GroupAnalyticsPage() { }, []) const loadGroups = useCallback(async () => { + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: '群列表加载', + detail: '正在读取群聊列表', + progressText: '群聊列表', + cancelable: true + }) setIsLoading(true) try { const result = await window.electronAPI.groupAnalytics.getGroupChats() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,群聊列表结果未继续写入' + }) + return + } if (result.success && result.data) { setGroups(result.data) setFilteredGroups(result.data) + finishBackgroundTask(taskId, 'completed', { + detail: `群聊列表加载完成,共 ${result.data.length} 个群`, + progressText: `${result.data.length} 个群` + }) + } else { + finishBackgroundTask(taskId, 'failed', { + detail: result.error || '加载群聊列表失败' + }) } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setIsLoading(false) } @@ -314,6 +344,13 @@ function GroupAnalyticsPage() { const loadFunctionData = async (func: AnalysisFunction) => { if (!selectedGroup) return + const taskId = registerBackgroundTask({ + sourcePage: 'groupAnalytics', + title: `群分析:${func}`, + detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`, + progressText: func, + cancelable: true + }) setFunctionLoading(true) // 计算时间戳 @@ -323,33 +360,96 @@ function GroupAnalyticsPage() { try { switch (func) { case 'members': { + updateBackgroundTask(taskId, { + detail: '正在读取群成员列表', + progressText: '成员列表' + }) const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群成员列表未继续写入' }) + return + } if (result.success && result.data) setMembers(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群成员列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取群成员列表失败'), + progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + }) break } case 'memberExport': { + updateBackgroundTask(taskId, { + detail: '正在读取导出成员列表', + progressText: '成员导出' + }) const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,成员导出列表未继续写入' }) + return + } if (result.success && result.data) setMembers(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `成员导出列表加载完成,共 ${result.data?.length || 0} 人` : (result.error || '读取成员导出列表失败'), + progressText: result.success ? `${result.data?.length || 0} 人` : '失败' + }) break } case 'ranking': { + updateBackgroundTask(taskId, { + detail: '正在计算群消息排行', + progressText: '消息排行' + }) const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息排行未继续写入' }) + return + } if (result.success && result.data) setRankings(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息排行加载完成,共 ${result.data?.length || 0} 条` : (result.error || '读取群消息排行失败'), + progressText: result.success ? `${result.data?.length || 0} 条` : '失败' + }) break } case 'activeHours': { + updateBackgroundTask(taskId, { + detail: '正在计算群活跃时段', + progressText: '活跃时段' + }) const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群活跃时段未继续写入' }) + return + } if (result.success && result.data) setActiveHours(result.data.hourlyDistribution) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? '群活跃时段加载完成' : (result.error || '读取群活跃时段失败'), + progressText: result.success ? '24 小时分布' : '失败' + }) break } case 'mediaStats': { + updateBackgroundTask(taskId, { + detail: '正在统计群消息类型', + progressText: '消息类型' + }) const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { detail: '已停止后续加载,群消息类型统计未继续写入' }) + return + } if (result.success && result.data) setMediaStats(result.data) + finishBackgroundTask(taskId, result.success ? 'completed' : 'failed', { + detail: result.success ? `群消息类型统计完成,共 ${result.data?.total || 0} 条` : (result.error || '读取群消息类型统计失败'), + progressText: result.success ? `${result.data?.total || 0} 条` : '失败' + }) break } } } catch (e) { console.error(e) + finishBackgroundTask(taskId, 'failed', { + detail: String(e) + }) } finally { setFunctionLoading(false) } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 0482240..52ca0b7 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel import JumpToDatePopover from '../components/JumpToDatePopover' import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog' import * as configService from '../services/config' +import { + finishBackgroundTask, + isBackgroundTaskCancelRequested, + registerBackgroundTask, + updateBackgroundTask +} from '../services/backgroundTaskMonitor' import { createExportDateRangeSelectionFromPreset, getExportDateRangeLabel, @@ -728,9 +734,23 @@ export default function SnsPage() { }) if (pendingTargets.length === 0) return + const taskId = registerBackgroundTask({ + sourcePage: 'sns', + title: '朋友圈联系人计数补算', + detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`, + progressText: `${preResolved}/${totalTargets}`, + cancelable: true + }) + let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前计数查询结束后不再继续分批写入' + }) + return + } if (runToken !== contactsCountHydrationTokenRef.current) return if (result.success && result.counts) { normalizedCounts = Object.fromEntries( @@ -747,12 +767,28 @@ export default function SnsPage() { } } catch (error) { console.error('Failed to load contact post counts:', error) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) + return } let resolved = preResolved let cursor = 0 const applyBatch = () => { if (runToken !== contactsCountHydrationTokenRef.current) return + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: `已停止后续加载,已完成 ${resolved}/${totalTargets}` + }) + contactsCountBatchTimerRef.current = null + setContactsCountProgress({ + resolved, + total: totalTargets, + running: false + }) + return + } const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) if (batch.length === 0) { @@ -762,6 +798,10 @@ export default function SnsPage() { running: false }) contactsCountBatchTimerRef.current = null + finishBackgroundTask(taskId, 'completed', { + detail: '联系人朋友圈条数补算完成', + progressText: `${totalTargets}/${totalTargets}` + }) return } @@ -789,6 +829,10 @@ export default function SnsPage() { total: totalTargets, running: resolved < totalTargets }) + updateBackgroundTask(taskId, { + detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`, + progressText: `${resolved}/${totalTargets}` + }) if (cursor < totalTargets) { contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) @@ -803,6 +847,13 @@ export default function SnsPage() { // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { const requestToken = ++contactsLoadTokenRef.current + const taskId = registerBackgroundTask({ + sourcePage: 'sns', + title: '朋友圈联系人列表加载', + detail: '准备读取联系人缓存与最近会话', + progressText: '初始化', + cancelable: true + }) stopContactsCountHydration(true) setContactsLoading(true) try { @@ -845,10 +896,20 @@ export default function SnsPage() { }) } + updateBackgroundTask(taskId, { + detail: '正在读取联系人与最近会话数据', + progressText: '联系人快照' + }) const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() ]) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,当前联系人查询结束后未继续补齐' + }) + return + } const contactMap = new Map() const sessionTimestampMap = new Map() @@ -904,7 +965,17 @@ export default function SnsPage() { // 用 enrichSessionsContactInfo 统一补充头像和显示名 if (allUsernames.length > 0) { + updateBackgroundTask(taskId, { + detail: '正在补齐联系人显示名与头像', + progressText: '联系人补齐' + }) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) + if (isBackgroundTaskCancelRequested(taskId)) { + finishBackgroundTask(taskId, 'canceled', { + detail: '已停止后续加载,联系人补齐未继续写入' + }) + return + } if (enriched.success && enriched.contacts) { contactsList = contactsList.map((contact) => { const extra = enriched.contacts?.[contact.username] @@ -931,10 +1002,17 @@ export default function SnsPage() { }) } } + finishBackgroundTask(taskId, 'completed', { + detail: `朋友圈联系人列表加载完成,共 ${contactsList.length} 人`, + progressText: `${contactsList.length} 人` + }) } catch (error) { if (requestToken !== contactsLoadTokenRef.current) return console.error('Failed to load contacts:', error) stopContactsCountHydration(true) + finishBackgroundTask(taskId, 'failed', { + detail: String(error) + }) } finally { if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) diff --git a/src/services/backgroundTaskMonitor.ts b/src/services/backgroundTaskMonitor.ts new file mode 100644 index 0000000..2b41f6d --- /dev/null +++ b/src/services/backgroundTaskMonitor.ts @@ -0,0 +1,149 @@ +import type { + BackgroundTaskInput, + BackgroundTaskRecord, + BackgroundTaskStatus, + BackgroundTaskUpdate +} from '../types/backgroundTask' + +type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void + +const tasks = new Map() +const cancelHandlers = new Map void | Promise>() +const listeners = new Set() +let taskSequence = 0 + +const ACTIVE_STATUSES = new Set(['running', 'cancel_requested']) +const MAX_SETTLED_TASKS = 24 + +const buildTaskId = (): string => { + taskSequence += 1 + return `bg-task-${Date.now()}-${taskSequence}` +} + +const notifyListeners = () => { + const snapshot = getBackgroundTaskSnapshot() + for (const listener of listeners) { + listener(snapshot) + } +} + +const pruneSettledTasks = () => { + const settledTasks = [...tasks.values()] + .filter(task => !ACTIVE_STATUSES.has(task.status)) + .sort((a, b) => (b.finishedAt || b.updatedAt) - (a.finishedAt || a.updatedAt)) + + for (const staleTask of settledTasks.slice(MAX_SETTLED_TASKS)) { + tasks.delete(staleTask.id) + } +} + +export const getBackgroundTaskSnapshot = (): BackgroundTaskRecord[] => ( + [...tasks.values()].sort((a, b) => { + const aActive = ACTIVE_STATUSES.has(a.status) ? 1 : 0 + const bActive = ACTIVE_STATUSES.has(b.status) ? 1 : 0 + if (aActive !== bActive) return bActive - aActive + return b.updatedAt - a.updatedAt + }) +) + +export const subscribeBackgroundTasks = (listener: BackgroundTaskListener): (() => void) => { + listeners.add(listener) + listener(getBackgroundTaskSnapshot()) + return () => { + listeners.delete(listener) + } +} + +export const registerBackgroundTask = (input: BackgroundTaskInput): string => { + const now = Date.now() + const taskId = buildTaskId() + tasks.set(taskId, { + id: taskId, + sourcePage: input.sourcePage, + title: input.title, + detail: input.detail, + progressText: input.progressText, + cancelable: input.cancelable !== false, + cancelRequested: false, + status: 'running', + startedAt: now, + updatedAt: now + }) + if (input.onCancel) { + cancelHandlers.set(taskId, input.onCancel) + } + pruneSettledTasks() + notifyListeners() + return taskId +} + +export const updateBackgroundTask = (taskId: string, patch: BackgroundTaskUpdate): void => { + const existing = tasks.get(taskId) + if (!existing) return + const nextStatus = patch.status || existing.status + const nextUpdatedAt = Date.now() + tasks.set(taskId, { + ...existing, + ...patch, + status: nextStatus, + updatedAt: nextUpdatedAt, + finishedAt: ACTIVE_STATUSES.has(nextStatus) ? undefined : (existing.finishedAt || nextUpdatedAt) + }) + pruneSettledTasks() + notifyListeners() +} + +export const finishBackgroundTask = ( + taskId: string, + status: Extract, + patch?: Omit +): void => { + const existing = tasks.get(taskId) + if (!existing) return + const now = Date.now() + tasks.set(taskId, { + ...existing, + ...patch, + status, + updatedAt: now, + finishedAt: now, + cancelRequested: status === 'canceled' ? true : existing.cancelRequested + }) + cancelHandlers.delete(taskId) + pruneSettledTasks() + notifyListeners() +} + +export const requestCancelBackgroundTask = (taskId: string): boolean => { + const existing = tasks.get(taskId) + if (!existing || !existing.cancelable || !ACTIVE_STATUSES.has(existing.status)) return false + tasks.set(taskId, { + ...existing, + status: 'cancel_requested', + cancelRequested: true, + detail: existing.detail || '停止请求已发出,当前查询完成后会结束后续加载', + updatedAt: Date.now() + }) + const cancelHandler = cancelHandlers.get(taskId) + if (cancelHandler) { + void Promise.resolve(cancelHandler()).catch(() => {}) + } + notifyListeners() + return true +} + +export const requestCancelBackgroundTasks = (predicate: (task: BackgroundTaskRecord) => boolean): number => { + let canceledCount = 0 + for (const task of tasks.values()) { + if (!predicate(task)) continue + if (requestCancelBackgroundTask(task.id)) { + canceledCount += 1 + } + } + return canceledCount +} + +export const isBackgroundTaskCancelRequested = (taskId: string): boolean => { + const task = tasks.get(taskId) + return Boolean(task?.cancelRequested) +} diff --git a/src/types/backgroundTask.ts b/src/types/backgroundTask.ts new file mode 100644 index 0000000..df8315e --- /dev/null +++ b/src/types/backgroundTask.ts @@ -0,0 +1,46 @@ +export type BackgroundTaskSourcePage = + | 'export' + | 'chat' + | 'analytics' + | 'sns' + | 'groupAnalytics' + | 'annualReport' + | 'other' + +export type BackgroundTaskStatus = + | 'running' + | 'cancel_requested' + | 'completed' + | 'failed' + | 'canceled' + +export interface BackgroundTaskRecord { + id: string + sourcePage: BackgroundTaskSourcePage + title: string + detail?: string + progressText?: string + cancelable: boolean + cancelRequested: boolean + status: BackgroundTaskStatus + startedAt: number + updatedAt: number + finishedAt?: number +} + +export interface BackgroundTaskInput { + sourcePage: BackgroundTaskSourcePage + title: string + detail?: string + progressText?: string + cancelable?: boolean + onCancel?: () => void | Promise +} + +export interface BackgroundTaskUpdate { + title?: string + detail?: string + progressText?: string + status?: BackgroundTaskStatus + cancelable?: boolean +}