feat(export): show and stop background page tasks

This commit is contained in:
aits2026
2026-03-06 19:16:07 +08:00
parent 5affd4e57b
commit ecd64f62bc
10 changed files with 883 additions and 4 deletions

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ContentType, string> = {
emoji: '表情包'
}
const backgroundTaskSourceLabels: Record<string, string> = {
export: '导出页',
chat: '聊天页',
analytics: '分析页',
sns: '朋友圈页',
groupAnalytics: '群分析页',
annualReport: '年度报告',
other: '其他页面'
}
const backgroundTaskStatusLabels: Record<BackgroundTaskRecord['status'], string> = {
running: '运行中',
cancel_requested: '停止中',
completed: '已完成',
failed: '失败',
canceled: '已停止'
}
const conversationTabLabels: Record<ConversationTab, string> = {
private: '私聊',
group: '群聊',
@@ -1422,6 +1446,7 @@ function ExportPage() {
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
const [exportFolder, setExportFolder] = useState('')
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('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<CSSProperties>(() => ({
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() {
</div>
<div className="session-load-detail-body">
<section className="session-load-detail-block">
<h5></h5>
<div className="session-load-detail-summary">
<div className="session-load-detail-summary-text">
<strong>{runningNonExportTaskCount}</strong>
<span></span>
{nonExportBackgroundTasksUpdatedAt > 0 && (
<em> {new Date(nonExportBackgroundTasksUpdatedAt).toLocaleTimeString('zh-CN', { hour12: false })}</em>
)}
</div>
<button
type="button"
className="session-load-detail-stop-btn"
onClick={handleCancelAllNonExportTasks}
disabled={cancelableNonExportTaskCount === 0}
>
</button>
</div>
<p className="session-load-detail-note">
</p>
{nonExportBackgroundTasks.length > 0 ? (
<div className="session-load-detail-task-list">
{nonExportBackgroundTasks.map((task) => (
<div key={task.id} className={`session-load-detail-task-item status-${task.status}`}>
<div className="session-load-detail-task-main">
<div className="session-load-detail-task-title-row">
<span className="session-load-detail-task-source">
{backgroundTaskSourceLabels[task.sourcePage] || backgroundTaskSourceLabels.other}
</span>
<strong>{task.title}</strong>
<span className={`session-load-detail-task-status status-${task.status}`}>
{backgroundTaskStatusLabels[task.status]}
</span>
</div>
<p>{task.detail || '暂无详细说明'}</p>
<div className="session-load-detail-task-meta">
<span>{formatLoadDetailTime(task.startedAt)}</span>
<span>{formatLoadDetailTime(task.updatedAt)}</span>
{task.progressText && <span>{task.progressText}</span>}
</div>
</div>
<button
type="button"
className="session-load-detail-task-stop-btn"
onClick={() => handleCancelBackgroundTask(task.id)}
disabled={!task.cancelable || (task.status !== 'running' && task.status !== 'cancel_requested')}
>
</button>
</div>
))}
</div>
) : (
<div className="session-load-detail-empty">
</div>
)}
</section>
<section className="session-load-detail-block">
<h5></h5>
<div className="session-load-detail-table">

View File

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

View File

@@ -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<string, number> = {}
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<string, Contact>()
const sessionTimestampMap = new Map<string, number>()
@@ -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)

View File

@@ -0,0 +1,149 @@
import type {
BackgroundTaskInput,
BackgroundTaskRecord,
BackgroundTaskStatus,
BackgroundTaskUpdate
} from '../types/backgroundTask'
type BackgroundTaskListener = (tasks: BackgroundTaskRecord[]) => void
const tasks = new Map<string, BackgroundTaskRecord>()
const cancelHandlers = new Map<string, () => void | Promise<void>>()
const listeners = new Set<BackgroundTaskListener>()
let taskSequence = 0
const ACTIVE_STATUSES = new Set<BackgroundTaskStatus>(['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<BackgroundTaskStatus, 'completed' | 'failed' | 'canceled'>,
patch?: Omit<BackgroundTaskUpdate, 'status'>
): 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)
}

View File

@@ -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<void>
}
export interface BackgroundTaskUpdate {
title?: string
detail?: string
progressText?: string
status?: BackgroundTaskStatus
cancelable?: boolean
}