mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): show and stop background page tasks
This commit is contained in:
@@ -4,6 +4,12 @@ import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw,
|
|||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
@@ -48,6 +54,13 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
const loadData = useCallback(async (forceRefresh = false) => {
|
const loadData = useCallback(async (forceRefresh = false) => {
|
||||||
if (isLoaded && !forceRefresh) return
|
if (isLoaded && !forceRefresh) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'analytics',
|
||||||
|
title: forceRefresh ? '刷新分析看板' : '加载分析看板',
|
||||||
|
detail: '准备读取整体统计数据',
|
||||||
|
progressText: '整体统计',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setProgress(0)
|
setProgress(0)
|
||||||
@@ -60,27 +73,70 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingStatus('正在统计消息数据...')
|
setLoadingStatus('正在统计消息数据...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在统计消息数据',
|
||||||
|
progressText: '整体统计'
|
||||||
|
})
|
||||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前页面分析流程已结束'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (statsResult.success && statsResult.data) {
|
if (statsResult.success && statsResult.data) {
|
||||||
setStatistics(statsResult.data)
|
setStatistics(statsResult.data)
|
||||||
} else {
|
} else {
|
||||||
setError(statsResult.error || '加载统计数据失败')
|
setError(statsResult.error || '加载统计数据失败')
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: statsResult.error || '加载统计数据失败'
|
||||||
|
})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setLoadingStatus('正在分析联系人排名...')
|
setLoadingStatus('正在分析联系人排名...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在分析联系人排名',
|
||||||
|
progressText: '联系人排名'
|
||||||
|
})
|
||||||
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
const rankingsResult = await window.electronAPI.analytics.getContactRankings(20)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,联系人排名后续步骤未继续'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (rankingsResult.success && rankingsResult.data) {
|
if (rankingsResult.success && rankingsResult.data) {
|
||||||
setRankings(rankingsResult.data)
|
setRankings(rankingsResult.data)
|
||||||
}
|
}
|
||||||
setLoadingStatus('正在计算时间分布...')
|
setLoadingStatus('正在计算时间分布...')
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算时间分布',
|
||||||
|
progressText: '时间分布'
|
||||||
|
})
|
||||||
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
const timeResult = await window.electronAPI.analytics.getTimeDistribution()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,时间分布结果未继续写入'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (timeResult.success && timeResult.data) {
|
if (timeResult.success && timeResult.data) {
|
||||||
setTimeDistribution(timeResult.data)
|
setTimeDistribution(timeResult.data)
|
||||||
}
|
}
|
||||||
markLoaded()
|
markLoaded()
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '分析看板数据加载完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
if (removeListener) removeListener()
|
if (removeListener) removeListener()
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnnualReportPage.scss'
|
import './AnnualReportPage.scss'
|
||||||
|
|
||||||
type YearOption = number | 'all'
|
type YearOption = number | 'all'
|
||||||
@@ -49,8 +55,17 @@ function AnnualReportPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
let taskId = ''
|
let taskId = ''
|
||||||
|
let uiTaskId = ''
|
||||||
|
|
||||||
const applyLoadPayload = (payload: YearsLoadPayload) => {
|
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.strategy) setLoadStrategy(payload.strategy)
|
||||||
if (payload.phase) setLoadPhase(payload.phase)
|
if (payload.phase) setLoadPhase(payload.phase)
|
||||||
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
|
||||||
@@ -91,6 +106,14 @@ function AnnualReportPage() {
|
|||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
setHasYearsLoadFinished(true)
|
setHasYearsLoadFinished(true)
|
||||||
setLoadPhase('done')
|
setLoadPhase('done')
|
||||||
|
if (uiTaskId) {
|
||||||
|
finishBackgroundTask(uiTaskId, payload.canceled ? 'canceled' : 'completed', {
|
||||||
|
detail: payload.canceled
|
||||||
|
? '年度报告年份加载已停止'
|
||||||
|
: `年度报告年份加载完成,共 ${years.length} 个年份`,
|
||||||
|
progressText: payload.canceled ? '已停止' : `${years.length} 个年份`
|
||||||
|
})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsLoadingMoreYears(true)
|
setIsLoadingMoreYears(true)
|
||||||
setHasYearsLoadFinished(false)
|
setHasYearsLoadFinished(false)
|
||||||
@@ -105,6 +128,18 @@ function AnnualReportPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const startLoad = async () => {
|
const startLoad = async () => {
|
||||||
|
uiTaskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'annualReport',
|
||||||
|
title: '年度报告年份加载',
|
||||||
|
detail: '准备使用原生快速模式加载年份',
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true,
|
||||||
|
onCancel: async () => {
|
||||||
|
if (taskId) {
|
||||||
|
await window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setIsLoadingMoreYears(true)
|
setIsLoadingMoreYears(true)
|
||||||
setHasYearsLoadFinished(false)
|
setHasYearsLoadFinished(false)
|
||||||
@@ -120,6 +155,9 @@ function AnnualReportPage() {
|
|||||||
try {
|
try {
|
||||||
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad()
|
||||||
if (!startResult.success || !startResult.taskId) {
|
if (!startResult.success || !startResult.taskId) {
|
||||||
|
finishBackgroundTask(uiTaskId, 'failed', {
|
||||||
|
detail: startResult.error || '加载年度数据失败'
|
||||||
|
})
|
||||||
setLoadError(startResult.error || '加载年度数据失败')
|
setLoadError(startResult.error || '加载年度数据失败')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
@@ -131,6 +169,9 @@ function AnnualReportPage() {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(uiTaskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
setLoadError(String(e))
|
setLoadError(String(e))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
setIsLoadingMoreYears(false)
|
setIsLoadingMoreYears(false)
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
|
|
||||||
// SVG 背景图案 (用于导出)
|
// SVG 背景图案 (用于导出)
|
||||||
@@ -164,6 +170,13 @@ function AnnualReportWindow() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const generateReport = async (year: number) => {
|
const generateReport = async (year: number) => {
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'annualReport',
|
||||||
|
title: '年度报告生成',
|
||||||
|
detail: `正在生成 ${formatYearLabel(year)} 年度报告`,
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoadingProgress(0)
|
setLoadingProgress(0)
|
||||||
@@ -171,25 +184,46 @@ function AnnualReportWindow() {
|
|||||||
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
const removeProgressListener = window.electronAPI.annualReport.onProgress?.((payload: { status: string; progress: number }) => {
|
||||||
setLoadingProgress(payload.progress)
|
setLoadingProgress(payload.progress)
|
||||||
setLoadingStage(payload.status)
|
setLoadingStage(payload.status)
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: payload.status || '正在生成年度报告',
|
||||||
|
progressText: `${Math.max(0, Math.round(payload.progress || 0))}%`
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.annualReport.generateReport(year)
|
const result = await window.electronAPI.annualReport.generateReport(year)
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前报告结果未继续写入页面'
|
||||||
|
})
|
||||||
|
setIsLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
setLoadingProgress(100)
|
setLoadingProgress(100)
|
||||||
setLoadingStage('完成')
|
setLoadingStage('完成')
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '年度报告生成完成',
|
||||||
|
progressText: '100%'
|
||||||
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setReportData(result.data!)
|
setReportData(result.data!)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, 300)
|
}, 300)
|
||||||
} else {
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: result.error || '生成年度报告失败'
|
||||||
|
})
|
||||||
setError(result.error || '生成报告失败')
|
setError(result.error || '生成报告失败')
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
removeProgressListener?.()
|
removeProgressListener?.()
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
setError(String(e))
|
setError(String(e))
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import JumpToDatePopover from '../components/JumpToDatePopover'
|
|||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
import { type ContactSnsTimelineTarget, isSingleContactSession } from '../components/Sns/contactSnsTimeline'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import {
|
import {
|
||||||
emitOpenSingleExport,
|
emitOpenSingleExport,
|
||||||
onExportSessionStatus,
|
onExportSessionStatus,
|
||||||
@@ -1067,6 +1073,13 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
if (!normalizedSessionId) return
|
if (!normalizedSessionId) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'chat',
|
||||||
|
title: '聊天页会话详情统计',
|
||||||
|
detail: `准备读取 ${sessionMapRef.current.get(normalizedSessionId)?.displayName || normalizedSessionId} 的详情`,
|
||||||
|
progressText: '基础信息',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
|
||||||
const requestSeq = ++detailRequestSeqRef.current
|
const requestSeq = ++detailRequestSeqRef.current
|
||||||
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
|
const mappedSession = sessionMapRef.current.get(normalizedSessionId) || sessionsRef.current.find((s) => s.username === normalizedSessionId)
|
||||||
@@ -1130,8 +1143,23 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取会话基础详情',
|
||||||
|
progressText: '基础信息'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.chat.getSessionDetailFast(normalizedSessionId)
|
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) {
|
if (result.success && result.detail) {
|
||||||
setSessionDetail((prev) => ({
|
setSessionDetail((prev) => ({
|
||||||
wxid: normalizedSessionId,
|
wxid: normalizedSessionId,
|
||||||
@@ -1170,6 +1198,10 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取补充信息与导出统计',
|
||||||
|
progressText: '补充统计'
|
||||||
|
})
|
||||||
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||||
window.electronAPI.chat.getExportSessionStats(
|
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) {
|
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
|
||||||
const detail = extraResultSettled.value.detail
|
const detail = extraResultSettled.value.detail
|
||||||
@@ -1214,8 +1257,15 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页会话详情统计完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载会话详情补充统计失败:', e)
|
console.error('加载会话详情补充统计失败:', e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsLoadingDetailExtra(false)
|
setIsLoadingDetailExtra(false)
|
||||||
@@ -1228,13 +1278,31 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
if (!normalizedSessionId || isLoadingRelationStats) return
|
if (!normalizedSessionId || isLoadingRelationStats) return
|
||||||
|
|
||||||
const requestSeq = detailRequestSeqRef.current
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'chat',
|
||||||
|
title: '聊天页关系统计补算',
|
||||||
|
detail: `正在补算 ${normalizedSessionId} 的共同好友与关联数据`,
|
||||||
|
progressText: '关系统计',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoadingRelationStats(true)
|
setIsLoadingRelationStats(true)
|
||||||
try {
|
try {
|
||||||
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ 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
|
const metric = relationResult.success && relationResult.data
|
||||||
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
@@ -1254,11 +1322,26 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
setIsRefreshingDetailStats(true)
|
setIsRefreshingDetailStats(true)
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在刷新关系统计结果',
|
||||||
|
progressText: '关系统计刷新'
|
||||||
|
})
|
||||||
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
[normalizedSessionId],
|
[normalizedSessionId],
|
||||||
{ includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true }
|
{ 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) {
|
if (freshResult.success && freshResult.data) {
|
||||||
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
@@ -1266,17 +1349,32 @@ function ChatPage(props: ChatPageProps) {
|
|||||||
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
|
applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页关系统计补算完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('刷新会话关系统计失败:', error)
|
console.error('刷新会话关系统计失败:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsRefreshingDetailStats(false)
|
setIsRefreshingDetailStats(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
} else {
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '聊天页关系统计补算完成',
|
||||||
|
progressText: '已完成'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载会话关系统计失败:', error)
|
console.error('加载会话关系统计失败:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === detailRequestSeqRef.current) {
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
setIsLoadingRelationStats(false)
|
setIsLoadingRelationStats(false)
|
||||||
|
|||||||
@@ -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 {
|
.session-load-detail-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
|
import type { ChatSession as AppChatSession, ContactInfo } from '../types/models'
|
||||||
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
|
import type { ExportOptions as ElectronExportOptions, ExportProgress } from '../types/electron'
|
||||||
|
import type { BackgroundTaskRecord } from '../types/backgroundTask'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
import {
|
import {
|
||||||
emitExportSessionStatus,
|
emitExportSessionStatus,
|
||||||
@@ -37,6 +38,11 @@ import {
|
|||||||
onExportSessionStatusRequest,
|
onExportSessionStatusRequest,
|
||||||
onOpenSingleExport
|
onOpenSingleExport
|
||||||
} from '../services/exportBridge'
|
} from '../services/exportBridge'
|
||||||
|
import {
|
||||||
|
requestCancelBackgroundTask,
|
||||||
|
requestCancelBackgroundTasks,
|
||||||
|
subscribeBackgroundTasks
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore'
|
||||||
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
import { SnsPostItem } from '../components/Sns/SnsPostItem'
|
||||||
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
import { ContactSnsTimelineDialog } from '../components/Sns/ContactSnsTimelineDialog'
|
||||||
@@ -176,6 +182,24 @@ const contentTypeLabels: Record<ContentType, string> = {
|
|||||||
emoji: '表情包'
|
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> = {
|
const conversationTabLabels: Record<ConversationTab, string> = {
|
||||||
private: '私聊',
|
private: '私聊',
|
||||||
group: '群聊',
|
group: '群聊',
|
||||||
@@ -1422,6 +1446,7 @@ function ExportPage() {
|
|||||||
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
|
const [sessionMutualFriendsMetrics, setSessionMutualFriendsMetrics] = useState<Record<string, SessionMutualFriendsMetric>>({})
|
||||||
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
const [sessionMutualFriendsDialogTarget, setSessionMutualFriendsDialogTarget] = useState<SessionSnsTimelineTarget | null>(null)
|
||||||
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
const [sessionMutualFriendsSearch, setSessionMutualFriendsSearch] = useState('')
|
||||||
|
const [backgroundTasks, setBackgroundTasks] = useState<BackgroundTaskRecord[]>([])
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
const [writeLayout, setWriteLayout] = useState<configService.ExportWriteLayout>('B')
|
||||||
@@ -1911,6 +1936,10 @@ function ExportPage() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
|
}, [contactsList.length, isContactsListLoading, contactsLoadIssue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeBackgroundTasks(setBackgroundTasks)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tasksRef.current = tasks
|
tasksRef.current = tasks
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
@@ -5499,6 +5528,16 @@ function ExportPage() {
|
|||||||
alert('复制失败,请手动复制诊断信息')
|
alert('复制失败,请手动复制诊断信息')
|
||||||
}
|
}
|
||||||
}, [contactsDiagnosticsText])
|
}, [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(() => {
|
const sessionContactsUpdatedAtLabel = useMemo(() => {
|
||||||
if (!sessionContactsUpdatedAt) return ''
|
if (!sessionContactsUpdatedAt) return ''
|
||||||
@@ -5586,6 +5625,21 @@ function ExportPage() {
|
|||||||
const contactsBottomScrollbarInnerStyle = useMemo<CSSProperties>(() => ({
|
const contactsBottomScrollbarInnerStyle = useMemo<CSSProperties>(() => ({
|
||||||
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
width: `${Math.max(contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth)}px`
|
||||||
}), [contactsHorizontalScrollMetrics.contentWidth, contactsHorizontalScrollMetrics.viewportWidth])
|
}), [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(() => {
|
const sessionLoadDetailUpdatedAt = useMemo(() => {
|
||||||
let latest = 0
|
let latest = 0
|
||||||
for (const row of sessionLoadDetailRows) {
|
for (const row of sessionLoadDetailRows) {
|
||||||
@@ -6422,6 +6476,67 @@ function ExportPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-load-detail-body">
|
<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">
|
<section className="session-load-detail-block">
|
||||||
<h5>总消息数</h5>
|
<h5>总消息数</h5>
|
||||||
<div className="session-load-detail-table">
|
<div className="session-load-detail-table">
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import { Avatar } from '../components/Avatar'
|
|||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import DateRangePicker from '../components/DateRangePicker'
|
import DateRangePicker from '../components/DateRangePicker'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import './GroupAnalyticsPage.scss'
|
import './GroupAnalyticsPage.scss'
|
||||||
|
|
||||||
interface GroupChatInfo {
|
interface GroupChatInfo {
|
||||||
@@ -176,15 +182,39 @@ function GroupAnalyticsPage() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadGroups = useCallback(async () => {
|
const loadGroups = useCallback(async () => {
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'groupAnalytics',
|
||||||
|
title: '群列表加载',
|
||||||
|
detail: '正在读取群聊列表',
|
||||||
|
progressText: '群聊列表',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
const result = await window.electronAPI.groupAnalytics.getGroupChats()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,群聊列表结果未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setGroups(result.data)
|
setGroups(result.data)
|
||||||
setFilteredGroups(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) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -314,6 +344,13 @@ function GroupAnalyticsPage() {
|
|||||||
|
|
||||||
const loadFunctionData = async (func: AnalysisFunction) => {
|
const loadFunctionData = async (func: AnalysisFunction) => {
|
||||||
if (!selectedGroup) return
|
if (!selectedGroup) return
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'groupAnalytics',
|
||||||
|
title: `群分析:${func}`,
|
||||||
|
detail: `正在读取 ${selectedGroup.displayName || selectedGroup.username} 的分析数据`,
|
||||||
|
progressText: func,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
setFunctionLoading(true)
|
setFunctionLoading(true)
|
||||||
|
|
||||||
// 计算时间戳
|
// 计算时间戳
|
||||||
@@ -323,33 +360,96 @@ function GroupAnalyticsPage() {
|
|||||||
try {
|
try {
|
||||||
switch (func) {
|
switch (func) {
|
||||||
case 'members': {
|
case 'members': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取群成员列表',
|
||||||
|
progressText: '成员列表'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
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)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'memberExport': {
|
case 'memberExport': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取导出成员列表',
|
||||||
|
progressText: '成员导出'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
|
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)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'ranking': {
|
case 'ranking': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算群消息排行',
|
||||||
|
progressText: '消息排行'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
|
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)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'activeHours': {
|
case 'activeHours': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在计算群活跃时段',
|
||||||
|
progressText: '活跃时段'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupActiveHours(selectedGroup.username, startTime, endTime)
|
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)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'mediaStats': {
|
case 'mediaStats': {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在统计群消息类型',
|
||||||
|
progressText: '消息类型'
|
||||||
|
})
|
||||||
const result = await window.electronAPI.groupAnalytics.getGroupMediaStats(selectedGroup.username, startTime, endTime)
|
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)
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(e)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setFunctionLoading(false)
|
setFunctionLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ import type { ContactSnsTimelineTarget } from '../components/Sns/contactSnsTimel
|
|||||||
import JumpToDatePopover from '../components/JumpToDatePopover'
|
import JumpToDatePopover from '../components/JumpToDatePopover'
|
||||||
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
import { ExportDateRangeDialog } from '../components/Export/ExportDateRangeDialog'
|
||||||
import * as configService from '../services/config'
|
import * as configService from '../services/config'
|
||||||
|
import {
|
||||||
|
finishBackgroundTask,
|
||||||
|
isBackgroundTaskCancelRequested,
|
||||||
|
registerBackgroundTask,
|
||||||
|
updateBackgroundTask
|
||||||
|
} from '../services/backgroundTaskMonitor'
|
||||||
import {
|
import {
|
||||||
createExportDateRangeSelectionFromPreset,
|
createExportDateRangeSelectionFromPreset,
|
||||||
getExportDateRangeLabel,
|
getExportDateRangeLabel,
|
||||||
@@ -728,9 +734,23 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
if (pendingTargets.length === 0) return
|
if (pendingTargets.length === 0) return
|
||||||
|
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'sns',
|
||||||
|
title: '朋友圈联系人计数补算',
|
||||||
|
detail: `正在补算 ${pendingTargets.length} 个联系人朋友圈条数`,
|
||||||
|
progressText: `${preResolved}/${totalTargets}`,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
|
||||||
let normalizedCounts: Record<string, number> = {}
|
let normalizedCounts: Record<string, number> = {}
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sns.getUserPostCounts()
|
const result = await window.electronAPI.sns.getUserPostCounts()
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前计数查询结束后不再继续分批写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
if (runToken !== contactsCountHydrationTokenRef.current) return
|
||||||
if (result.success && result.counts) {
|
if (result.success && result.counts) {
|
||||||
normalizedCounts = Object.fromEntries(
|
normalizedCounts = Object.fromEntries(
|
||||||
@@ -747,12 +767,28 @@ export default function SnsPage() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load contact post counts:', error)
|
console.error('Failed to load contact post counts:', error)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let resolved = preResolved
|
let resolved = preResolved
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const applyBatch = () => {
|
const applyBatch = () => {
|
||||||
if (runToken !== contactsCountHydrationTokenRef.current) return
|
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)
|
const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE)
|
||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
@@ -762,6 +798,10 @@ export default function SnsPage() {
|
|||||||
running: false
|
running: false
|
||||||
})
|
})
|
||||||
contactsCountBatchTimerRef.current = null
|
contactsCountBatchTimerRef.current = null
|
||||||
|
finishBackgroundTask(taskId, 'completed', {
|
||||||
|
detail: '联系人朋友圈条数补算完成',
|
||||||
|
progressText: `${totalTargets}/${totalTargets}`
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -789,6 +829,10 @@ export default function SnsPage() {
|
|||||||
total: totalTargets,
|
total: totalTargets,
|
||||||
running: resolved < totalTargets
|
running: resolved < totalTargets
|
||||||
})
|
})
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: `已完成 ${resolved}/${totalTargets} 个联系人朋友圈条数补算`,
|
||||||
|
progressText: `${resolved}/${totalTargets}`
|
||||||
|
})
|
||||||
|
|
||||||
if (cursor < totalTargets) {
|
if (cursor < totalTargets) {
|
||||||
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS)
|
||||||
@@ -803,6 +847,13 @@ export default function SnsPage() {
|
|||||||
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
// Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序)
|
||||||
const loadContacts = useCallback(async () => {
|
const loadContacts = useCallback(async () => {
|
||||||
const requestToken = ++contactsLoadTokenRef.current
|
const requestToken = ++contactsLoadTokenRef.current
|
||||||
|
const taskId = registerBackgroundTask({
|
||||||
|
sourcePage: 'sns',
|
||||||
|
title: '朋友圈联系人列表加载',
|
||||||
|
detail: '准备读取联系人缓存与最近会话',
|
||||||
|
progressText: '初始化',
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
setContactsLoading(true)
|
setContactsLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -845,10 +896,20 @@ export default function SnsPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在读取联系人与最近会话数据',
|
||||||
|
progressText: '联系人快照'
|
||||||
|
})
|
||||||
const [contactsResult, sessionsResult] = await Promise.all([
|
const [contactsResult, sessionsResult] = await Promise.all([
|
||||||
window.electronAPI.chat.getContacts(),
|
window.electronAPI.chat.getContacts(),
|
||||||
window.electronAPI.chat.getSessions()
|
window.electronAPI.chat.getSessions()
|
||||||
])
|
])
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,当前联系人查询结束后未继续补齐'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
const contactMap = new Map<string, Contact>()
|
const contactMap = new Map<string, Contact>()
|
||||||
const sessionTimestampMap = new Map<string, number>()
|
const sessionTimestampMap = new Map<string, number>()
|
||||||
|
|
||||||
@@ -904,7 +965,17 @@ export default function SnsPage() {
|
|||||||
|
|
||||||
// 用 enrichSessionsContactInfo 统一补充头像和显示名
|
// 用 enrichSessionsContactInfo 统一补充头像和显示名
|
||||||
if (allUsernames.length > 0) {
|
if (allUsernames.length > 0) {
|
||||||
|
updateBackgroundTask(taskId, {
|
||||||
|
detail: '正在补齐联系人显示名与头像',
|
||||||
|
progressText: '联系人补齐'
|
||||||
|
})
|
||||||
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames)
|
||||||
|
if (isBackgroundTaskCancelRequested(taskId)) {
|
||||||
|
finishBackgroundTask(taskId, 'canceled', {
|
||||||
|
detail: '已停止后续加载,联系人补齐未继续写入'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
if (enriched.success && enriched.contacts) {
|
if (enriched.success && enriched.contacts) {
|
||||||
contactsList = contactsList.map((contact) => {
|
contactsList = contactsList.map((contact) => {
|
||||||
const extra = enriched.contacts?.[contact.username]
|
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) {
|
} catch (error) {
|
||||||
if (requestToken !== contactsLoadTokenRef.current) return
|
if (requestToken !== contactsLoadTokenRef.current) return
|
||||||
console.error('Failed to load contacts:', error)
|
console.error('Failed to load contacts:', error)
|
||||||
stopContactsCountHydration(true)
|
stopContactsCountHydration(true)
|
||||||
|
finishBackgroundTask(taskId, 'failed', {
|
||||||
|
detail: String(error)
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
if (requestToken === contactsLoadTokenRef.current) {
|
if (requestToken === contactsLoadTokenRef.current) {
|
||||||
setContactsLoading(false)
|
setContactsLoading(false)
|
||||||
|
|||||||
149
src/services/backgroundTaskMonitor.ts
Normal file
149
src/services/backgroundTaskMonitor.ts
Normal 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)
|
||||||
|
}
|
||||||
46
src/types/backgroundTask.ts
Normal file
46
src/types/backgroundTask.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user