diff --git a/electron/main.ts b/electron/main.ts index 4c97fb5..4bb3c27 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -102,6 +102,7 @@ interface ExportTaskControlState { const exportTaskControlMap = new Map() const pendingExportTaskControlMap = new Map() +const annualReportYearsLoadTasks = new Map() const getTaskControlState = (taskId?: string): ExportTaskControlState | null => { const normalized = typeof taskId === 'string' ? taskId.trim() : '' @@ -121,6 +122,10 @@ const createTaskControlState = (taskId?: string): string | null => { return normalized } +const isYearsLoadCanceled = (taskId: string): boolean => { + return annualReportYearsLoadTasks.get(taskId)?.canceled === true +} + const clearTaskControlState = (taskId?: string): void => { const normalized = typeof taskId === 'string' ? taskId.trim() : '' if (!normalized) return @@ -1534,6 +1539,67 @@ function registerIpcHandlers() { }) }) + ipcMain.handle('annualReport:startAvailableYearsLoad', async (event) => { + const cfg = configService || new ConfigService() + configService = cfg + + const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` + annualReportYearsLoadTasks.set(taskId, { canceled: false }) + const sender = event.sender + + const sendProgress = (payload: { years?: number[]; done: boolean; error?: string; canceled?: boolean }) => { + if (!sender.isDestroyed()) { + sender.send('annualReport:availableYearsProgress', { + taskId, + ...payload + }) + } + } + + void (async () => { + try { + const result = await annualReportService.getAvailableYears({ + dbPath: cfg.get('dbPath'), + decryptKey: cfg.get('decryptKey'), + wxid: cfg.get('myWxid'), + onProgress: (years) => { + if (isYearsLoadCanceled(taskId)) return + sendProgress({ years, done: false }) + }, + shouldCancel: () => isYearsLoadCanceled(taskId) + }) + + const canceled = isYearsLoadCanceled(taskId) + annualReportYearsLoadTasks.delete(taskId) + if (canceled) { + sendProgress({ done: true, canceled: true }) + return + } + + if (result.success) { + sendProgress({ years: result.data || [], done: true }) + } else { + sendProgress({ years: result.data || [], done: true, error: result.error || '加载年度数据失败' }) + } + } catch (e) { + annualReportYearsLoadTasks.delete(taskId) + sendProgress({ done: true, error: String(e) }) + } + })() + + return { success: true, taskId } + }) + + ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => { + const key = String(taskId || '').trim() + if (!key) return { success: false, error: '任务ID不能为空' } + const task = annualReportYearsLoadTasks.get(key) + if (!task) return { success: true } + task.canceled = true + annualReportYearsLoadTasks.set(key, task) + return { success: true } + }) + ipcMain.handle('annualReport:generateReport', async (_, year: number) => { const cfg = configService || new ConfigService() configService = cfg diff --git a/electron/preload.ts b/electron/preload.ts index 55526e3..baeb5c2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -265,9 +265,15 @@ contextBridge.exposeInMainWorld('electronAPI', { // 年度报告 annualReport: { getAvailableYears: () => ipcRenderer.invoke('annualReport:getAvailableYears'), + startAvailableYearsLoad: () => ipcRenderer.invoke('annualReport:startAvailableYearsLoad'), + cancelAvailableYearsLoad: (taskId: string) => ipcRenderer.invoke('annualReport:cancelAvailableYearsLoad', taskId), generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => ipcRenderer.invoke('annualReport:exportImages', payload), + onAvailableYearsProgress: (callback: (payload: { taskId: string; years?: number[]; done: boolean; error?: string; canceled?: boolean }) => void) => { + ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload)) + return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress') + }, onProgress: (callback: (payload: { status: string; progress: number }) => void) => { ipcRenderer.on('annualReport:progress', (_, payload) => callback(payload)) return () => ipcRenderer.removeAllListeners('annualReport:progress') diff --git a/electron/services/annualReportService.ts b/electron/services/annualReportService.ts index 0de611e..4aa7722 100644 --- a/electron/services/annualReportService.ts +++ b/electron/services/annualReportService.ts @@ -198,23 +198,36 @@ class AnnualReportService { return seconds > 0 ? seconds : 0 } - private addYearsFromRange(years: Set, firstTs: number, lastTs: number): void { + private addYearsFromRange(years: Set, firstTs: number, lastTs: number): boolean { + let changed = false const currentYear = new Date().getFullYear() const minTs = firstTs > 0 ? firstTs : lastTs const maxTs = lastTs > 0 ? lastTs : firstTs - if (minTs <= 0 || maxTs <= 0) return + if (minTs <= 0 || maxTs <= 0) return changed const minYear = new Date(minTs * 1000).getFullYear() const maxYear = new Date(maxTs * 1000).getFullYear() for (let y = minYear; y <= maxYear; y++) { - if (y >= 2010 && y <= currentYear) years.add(y) + if (y >= 2010 && y <= currentYear && !years.has(y)) { + years.add(y) + changed = true + } } + return changed + } + + private normalizeAvailableYears(years: Iterable): number[] { + return Array.from(new Set(Array.from(years))) + .filter((y) => Number.isFinite(y)) + .map((y) => Math.floor(y)) + .sort((a, b) => b - a) } private async forEachWithConcurrency( items: T[], concurrency: number, - handler: (item: T, index: number) => Promise + handler: (item: T, index: number) => Promise, + shouldStop?: () => boolean ): Promise { if (!items.length) return const workerCount = Math.max(1, Math.min(concurrency, items.length)) @@ -224,6 +237,7 @@ class AnnualReportService { for (let i = 0; i < workerCount; i++) { workers.push((async () => { while (true) { + if (shouldStop?.()) break const current = nextIndex nextIndex += 1 if (current >= items.length) break @@ -297,37 +311,72 @@ class AnnualReportService { return queryByColumn(detectedColumn) } - private async getAvailableYearsByTableScan(sessionIds: string[]): Promise { + private async getAvailableYearsByTableScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { const years = new Set() + let lastEmittedSize = 0 + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + + const shouldCancel = () => options?.shouldCancel?.() === true await this.forEachWithConcurrency(sessionIds, this.availableYearsScanConcurrency, async (sessionId) => { + if (shouldCancel()) return const tableStats = await wcdbService.getMessageTableStats(sessionId) if (!tableStats.success || !Array.isArray(tableStats.tables) || tableStats.tables.length === 0) { return } for (const table of tableStats.tables as Record[]) { + if (shouldCancel()) return const tableName = String(table.table_name || table.name || '').trim() const dbPath = String(table.db_path || table.dbPath || '').trim() if (!tableName || !dbPath) continue const range = await this.getTableTimeRange(dbPath, tableName) if (!range) continue - this.addYearsFromRange(years, range.first, range.last) + const changed = this.addYearsFromRange(years, range.first, range.last) + if (changed) emitIfChanged() } - }) + }, shouldCancel) - return Array.from(years).sort((a, b) => b - a) + emitIfChanged(true) + return this.normalizeAvailableYears(years) } - private async getAvailableYearsByEdgeScan(sessionIds: string[]): Promise { + private async getAvailableYearsByEdgeScan( + sessionIds: string[], + options?: { onProgress?: (years: number[]) => void; shouldCancel?: () => boolean } + ): Promise { const years = new Set() + let lastEmittedSize = 0 + const shouldCancel = () => options?.shouldCancel?.() === true + + const emitIfChanged = (force = false) => { + if (!options?.onProgress) return + const next = this.normalizeAvailableYears(years) + if (!force && next.length === lastEmittedSize) return + options.onProgress(next) + lastEmittedSize = next.length + } + for (const sessionId of sessionIds) { + if (shouldCancel()) break const first = await this.getEdgeMessageTime(sessionId, true) const last = await this.getEdgeMessageTime(sessionId, false) - this.addYearsFromRange(years, first || 0, last || 0) + const changed = this.addYearsFromRange(years, first || 0, last || 0) + if (changed) emitIfChanged() } - return Array.from(years).sort((a, b) => b - a) + emitIfChanged(true) + return this.normalizeAvailableYears(years) } private buildAvailableYearsCacheKey(dbPath: string, cleanedWxid: string): string { @@ -345,10 +394,7 @@ class AnnualReportService { } private setCachedAvailableYears(cacheKey: string, years: number[]): void { - const normalized = Array.from(new Set(years)) - .filter((y) => Number.isFinite(y)) - .map((y) => Math.floor(y)) - .sort((a, b) => b - a) + const normalized = this.normalizeAvailableYears(years) this.availableYearsCache.set(cacheKey, { years: normalized, @@ -546,13 +592,22 @@ class AnnualReportService { return { sessionId: bestSessionId, days: bestDays, start: bestStart, end: bestEnd } } - async getAvailableYears(params: { dbPath: string; decryptKey: string; wxid: string }): Promise<{ success: boolean; data?: number[]; error?: string }> { + async getAvailableYears(params: { + dbPath: string + decryptKey: string + wxid: string + onProgress?: (years: number[]) => void + shouldCancel?: () => boolean + }): Promise<{ success: boolean; data?: number[]; error?: string }> { try { + const isCancelled = () => params.shouldCancel?.() === true const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } + if (isCancelled()) return { success: false, error: '已取消加载年份数据' } const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) const cached = this.getCachedAvailableYears(cacheKey) if (cached) { + params.onProgress?.(cached) return { success: true, data: cached } } @@ -560,14 +615,24 @@ class AnnualReportService { if (sessionIds.length === 0) { return { success: false, error: '未找到消息会话' } } + if (isCancelled()) return { success: false, error: '已取消加载年份数据' } - let years = await this.getAvailableYearsByTableScan(sessionIds) + let years = await this.getAvailableYearsByTableScan(sessionIds, { + onProgress: params.onProgress, + shouldCancel: params.shouldCancel + }) + if (isCancelled()) return { success: false, error: '已取消加载年份数据' } if (years.length === 0) { // 扫表失败时,再降级到游标首尾扫描,保证兼容性。 - years = await this.getAvailableYearsByEdgeScan(sessionIds) + years = await this.getAvailableYearsByEdgeScan(sessionIds, { + onProgress: params.onProgress, + shouldCancel: params.shouldCancel + }) } + if (isCancelled()) return { success: false, error: '已取消加载年份数据' } this.setCachedAvailableYears(cacheKey, years) + params.onProgress?.(years) return { success: true, data: years } } catch (e) { return { success: false, error: String(e) } diff --git a/src/pages/AnnualReportPage.tsx b/src/pages/AnnualReportPage.tsx index 3aa49b1..a346c85 100644 --- a/src/pages/AnnualReportPage.tsx +++ b/src/pages/AnnualReportPage.tsx @@ -11,33 +11,79 @@ function AnnualReportPage() { const [selectedYear, setSelectedYear] = useState(null) const [selectedPairYear, setSelectedPairYear] = useState(null) const [isLoading, setIsLoading] = useState(true) + const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false) const [isGenerating, setIsGenerating] = useState(false) const [loadError, setLoadError] = useState(null) useEffect(() => { - loadAvailableYears() - }, []) + let disposed = false + let taskId = '' - const loadAvailableYears = async () => { - setIsLoading(true) - setLoadError(null) - try { - const result = await window.electronAPI.annualReport.getAvailableYears() - const years = result.data - if (result.success && Array.isArray(years) && years.length > 0) { + const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => { + if (disposed) return + if (taskId && payload.taskId !== taskId) return + if (!taskId) taskId = payload.taskId + + const years = Array.isArray(payload.years) ? payload.years : [] + if (years.length > 0) { setAvailableYears(years) - setSelectedYear((prev) => prev ?? years[0]) - setSelectedPairYear((prev) => prev ?? years[0]) - } else if (!result.success) { - setLoadError(result.error || '加载年度数据失败') + setSelectedYear((prev) => { + if (prev === 'all') return prev + if (typeof prev === 'number' && years.includes(prev)) return prev + return years[0] + }) + setSelectedPairYear((prev) => { + if (prev === 'all') return prev + if (typeof prev === 'number' && years.includes(prev)) return prev + return years[0] + }) + // 只要有首批年份可选,就不再阻塞整个页面。 + setIsLoading(false) + } + + if (payload.error && !payload.canceled) { + setLoadError(payload.error || '加载年度数据失败') + } + + if (payload.done) { + setIsLoading(false) + setIsLoadingMoreYears(false) + } else { + setIsLoadingMoreYears(true) + } + }) + + const startLoad = async () => { + setIsLoading(true) + setIsLoadingMoreYears(true) + setLoadError(null) + try { + const startResult = await window.electronAPI.annualReport.startAvailableYearsLoad() + if (!startResult.success || !startResult.taskId) { + setLoadError(startResult.error || '加载年度数据失败') + setIsLoading(false) + setIsLoadingMoreYears(false) + return + } + taskId = startResult.taskId + } catch (e) { + console.error(e) + setLoadError(String(e)) + setIsLoading(false) + setIsLoadingMoreYears(false) } - } catch (e) { - console.error(e) - setLoadError(String(e)) - } finally { - setIsLoading(false) } - } + + void startLoad() + + return () => { + disposed = true + stopListen() + if (taskId) { + void window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId) + } + } + }, []) const handleGenerateReport = async () => { if (selectedYear === null) return @@ -58,16 +104,16 @@ function AnnualReportPage() { navigate(`/dual-report?year=${yearParam}`) } - if (isLoading) { + if (isLoading && availableYears.length === 0) { return (
-

正在加载年份数据...

+

正在加载年份数据(首批)...

) } - if (availableYears.length === 0) { + if (availableYears.length === 0 && !isLoadingMoreYears) { return (
@@ -93,6 +139,9 @@ function AnnualReportPage() {

年度报告

选择年份,回顾你在微信里的点点滴滴

+ {isLoadingMoreYears && ( +

已显示首批年份,正在补充更多年份...

+ )}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 2af6496..7d2fec7 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -483,6 +483,15 @@ export interface ElectronAPI { data?: number[] error?: string }> + startAvailableYearsLoad: () => Promise<{ + success: boolean + taskId?: string + error?: string + }> + cancelAvailableYearsLoad: (taskId: string) => Promise<{ + success: boolean + error?: string + }> generateReport: (year: number) => Promise<{ success: boolean data?: { @@ -567,6 +576,13 @@ export interface ElectronAPI { dir?: string error?: string }> + onAvailableYearsProgress: (callback: (payload: { + taskId: string + years?: number[] + done: boolean + error?: string + canceled?: boolean + }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void } dualReport: {