feat(report): reuse years loading across page switches

This commit is contained in:
tisonhuang
2026-03-04 17:07:00 +08:00
parent 423d760f36
commit e1944783d0
6 changed files with 621 additions and 76 deletions

View File

@@ -100,9 +100,38 @@ interface ExportTaskControlState {
stopRequested: boolean stopRequested: boolean
} }
type AnnualReportYearsLoadStrategy = 'cache' | 'native' | 'hybrid'
type AnnualReportYearsLoadPhase = 'cache' | 'native' | 'scan' | 'done'
interface AnnualReportYearsProgressPayload {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: AnnualReportYearsLoadStrategy
phase?: AnnualReportYearsLoadPhase
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AnnualReportYearsTaskState {
cacheKey: string
canceled: boolean
done: boolean
snapshot: AnnualReportYearsProgressPayload
updatedAt: number
}
const exportTaskControlMap = new Map<string, ExportTaskControlState>() const exportTaskControlMap = new Map<string, ExportTaskControlState>()
const pendingExportTaskControlMap = new Map<string, ExportTaskControlState>() const pendingExportTaskControlMap = new Map<string, ExportTaskControlState>()
const annualReportYearsLoadTasks = new Map<string, { canceled: boolean }>() const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
const annualReportYearsSnapshotTtlMs = 10 * 60 * 1000
const getTaskControlState = (taskId?: string): ExportTaskControlState | null => { const getTaskControlState = (taskId?: string): ExportTaskControlState | null => {
const normalized = typeof taskId === 'string' ? taskId.trim() : '' const normalized = typeof taskId === 'string' ? taskId.trim() : ''
@@ -122,8 +151,65 @@ const createTaskControlState = (taskId?: string): string | null => {
return normalized return normalized
} }
const normalizeAnnualReportYearsSnapshot = (snapshot: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload => {
const years = Array.isArray(snapshot.years) ? [...snapshot.years] : []
return { ...snapshot, years }
}
const buildAnnualReportYearsCacheKey = (dbPath: string, wxid: string): string => {
return `${String(dbPath || '').trim()}\u0001${String(wxid || '').trim()}`
}
const pruneAnnualReportYearsSnapshotCache = (): void => {
const now = Date.now()
for (const [cacheKey, entry] of annualReportYearsSnapshotCache.entries()) {
if (now - entry.updatedAt > annualReportYearsSnapshotTtlMs) {
annualReportYearsSnapshotCache.delete(cacheKey)
}
}
}
const persistAnnualReportYearsSnapshot = (
cacheKey: string,
taskId: string,
snapshot: AnnualReportYearsProgressPayload
): void => {
annualReportYearsSnapshotCache.set(cacheKey, {
taskId,
snapshot: normalizeAnnualReportYearsSnapshot(snapshot),
updatedAt: Date.now()
})
pruneAnnualReportYearsSnapshotCache()
}
const getAnnualReportYearsSnapshot = (
cacheKey: string
): { taskId: string; snapshot: AnnualReportYearsProgressPayload } | null => {
pruneAnnualReportYearsSnapshotCache()
const entry = annualReportYearsSnapshotCache.get(cacheKey)
if (!entry) return null
return {
taskId: entry.taskId,
snapshot: normalizeAnnualReportYearsSnapshot(entry.snapshot)
}
}
const broadcastAnnualReportYearsProgress = (
taskId: string,
payload: AnnualReportYearsProgressPayload
): void => {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed()) continue
win.webContents.send('annualReport:availableYearsProgress', {
taskId,
...payload
})
}
}
const isYearsLoadCanceled = (taskId: string): boolean => { const isYearsLoadCanceled = (taskId: string): boolean => {
return annualReportYearsLoadTasks.get(taskId)?.canceled === true const task = annualReportYearsLoadTasks.get(taskId)
return task?.canceled === true
} }
const clearTaskControlState = (taskId?: string): void => { const clearTaskControlState = (taskId?: string): void => {
@@ -1543,51 +1629,178 @@ function registerIpcHandlers() {
const cfg = configService || new ConfigService() const cfg = configService || new ConfigService()
configService = cfg configService = cfg
const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` const dbPath = cfg.get('dbPath')
annualReportYearsLoadTasks.set(taskId, { canceled: false }) const decryptKey = cfg.get('decryptKey')
const sender = event.sender const wxid = cfg.get('myWxid')
const cacheKey = buildAnnualReportYearsCacheKey(dbPath, wxid)
const sendProgress = (payload: { years?: number[]; done: boolean; error?: string; canceled?: boolean }) => { const runningTaskId = annualReportYearsTaskByCacheKey.get(cacheKey)
if (!sender.isDestroyed()) { if (runningTaskId) {
sender.send('annualReport:availableYearsProgress', { const runningTask = annualReportYearsLoadTasks.get(runningTaskId)
taskId, if (runningTask && !runningTask.done) {
...payload return {
}) success: true,
taskId: runningTaskId,
reused: true,
snapshot: normalizeAnnualReportYearsSnapshot(runningTask.snapshot)
}
}
annualReportYearsTaskByCacheKey.delete(cacheKey)
}
const cachedSnapshot = getAnnualReportYearsSnapshot(cacheKey)
if (cachedSnapshot && cachedSnapshot.snapshot.done) {
return {
success: true,
taskId: cachedSnapshot.taskId,
reused: true,
snapshot: normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot)
} }
} }
const taskId = `years_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
const initialSnapshot: AnnualReportYearsProgressPayload = cachedSnapshot?.snapshot && !cachedSnapshot.snapshot.done
? {
...normalizeAnnualReportYearsSnapshot(cachedSnapshot.snapshot),
done: false,
canceled: false,
error: undefined
}
: {
years: [],
done: false,
strategy: 'native',
phase: 'native',
statusText: '准备使用原生快速模式加载年份...',
nativeElapsedMs: 0,
scanElapsedMs: 0,
totalElapsedMs: 0,
switched: false,
nativeTimedOut: false
}
const updateTaskSnapshot = (payload: AnnualReportYearsProgressPayload): AnnualReportYearsProgressPayload | null => {
const task = annualReportYearsLoadTasks.get(taskId)
if (!task) return null
const hasPayloadYears = Array.isArray(payload.years)
const nextYears = (hasPayloadYears && (payload.done || (payload.years || []).length > 0))
? [...(payload.years || [])]
: Array.isArray(task.snapshot.years) ? [...task.snapshot.years] : []
const nextSnapshot: AnnualReportYearsProgressPayload = normalizeAnnualReportYearsSnapshot({
...task.snapshot,
...payload,
years: nextYears
})
task.snapshot = nextSnapshot
task.done = nextSnapshot.done === true
task.updatedAt = Date.now()
annualReportYearsLoadTasks.set(taskId, task)
persistAnnualReportYearsSnapshot(task.cacheKey, taskId, nextSnapshot)
return nextSnapshot
}
annualReportYearsLoadTasks.set(taskId, {
cacheKey,
canceled: false,
done: false,
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot),
updatedAt: Date.now()
})
annualReportYearsTaskByCacheKey.set(cacheKey, taskId)
persistAnnualReportYearsSnapshot(cacheKey, taskId, initialSnapshot)
void (async () => { void (async () => {
try { try {
const result = await annualReportService.getAvailableYears({ const result = await annualReportService.getAvailableYears({
dbPath: cfg.get('dbPath'), dbPath,
decryptKey: cfg.get('decryptKey'), decryptKey,
wxid: cfg.get('myWxid'), wxid,
onProgress: (years) => { nativeTimeoutMs: 5000,
onProgress: (progress) => {
if (isYearsLoadCanceled(taskId)) return if (isYearsLoadCanceled(taskId)) return
sendProgress({ years, done: false }) const snapshot = updateTaskSnapshot({
...progress,
done: false
})
if (!snapshot) return
broadcastAnnualReportYearsProgress(taskId, snapshot)
}, },
shouldCancel: () => isYearsLoadCanceled(taskId) shouldCancel: () => isYearsLoadCanceled(taskId)
}) })
const canceled = isYearsLoadCanceled(taskId) const canceled = isYearsLoadCanceled(taskId)
annualReportYearsLoadTasks.delete(taskId)
if (canceled) { if (canceled) {
sendProgress({ done: true, canceled: true }) const snapshot = updateTaskSnapshot({
done: true,
canceled: true,
phase: 'done',
statusText: '已取消年份加载'
})
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
}
return return
} }
if (result.success) { const completionPayload: AnnualReportYearsProgressPayload = result.success
sendProgress({ years: result.data || [], done: true }) ? {
} else { years: result.data || [],
sendProgress({ years: result.data || [], done: true, error: result.error || '加载年度数据失败' }) done: true,
strategy: result.meta?.strategy,
phase: 'done',
statusText: result.meta?.statusText || '年份数据加载完成',
nativeElapsedMs: result.meta?.nativeElapsedMs,
scanElapsedMs: result.meta?.scanElapsedMs,
totalElapsedMs: result.meta?.totalElapsedMs,
switched: result.meta?.switched,
nativeTimedOut: result.meta?.nativeTimedOut
}
: {
years: result.data || [],
done: true,
error: result.error || '加载年度数据失败',
strategy: result.meta?.strategy,
phase: 'done',
statusText: result.meta?.statusText || '年份数据加载失败',
nativeElapsedMs: result.meta?.nativeElapsedMs,
scanElapsedMs: result.meta?.scanElapsedMs,
totalElapsedMs: result.meta?.totalElapsedMs,
switched: result.meta?.switched,
nativeTimedOut: result.meta?.nativeTimedOut
}
const snapshot = updateTaskSnapshot(completionPayload)
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
} }
} catch (e) { } catch (e) {
const snapshot = updateTaskSnapshot({
done: true,
error: String(e),
phase: 'done',
statusText: '年份数据加载失败',
strategy: 'hybrid'
})
if (snapshot) {
broadcastAnnualReportYearsProgress(taskId, snapshot)
}
} finally {
const task = annualReportYearsLoadTasks.get(taskId)
if (task) {
annualReportYearsTaskByCacheKey.delete(task.cacheKey)
}
annualReportYearsLoadTasks.delete(taskId) annualReportYearsLoadTasks.delete(taskId)
sendProgress({ done: true, error: String(e) })
} }
})() })()
return { success: true, taskId } return {
success: true,
taskId,
reused: false,
snapshot: normalizeAnnualReportYearsSnapshot(initialSnapshot)
}
}) })
ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => { ipcMain.handle('annualReport:cancelAvailableYearsLoad', async (_, taskId: string) => {

View File

@@ -270,7 +270,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year), generateReport: (year: number) => ipcRenderer.invoke('annualReport:generateReport', year),
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) =>
ipcRenderer.invoke('annualReport:exportImages', payload), ipcRenderer.invoke('annualReport:exportImages', payload),
onAvailableYearsProgress: (callback: (payload: { taskId: string; years?: number[]; done: boolean; error?: string; canceled?: boolean }) => void) => { onAvailableYearsProgress: (callback: (payload: {
taskId: string
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => {
ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload)) ipcRenderer.on('annualReport:availableYearsProgress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress') return () => ipcRenderer.removeAllListeners('annualReport:availableYearsProgress')
}, },

View File

@@ -85,6 +85,28 @@ export interface AnnualReportData {
} | null } | null
} }
export interface AvailableYearsLoadProgress {
years: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched?: boolean
nativeTimedOut?: boolean
}
interface AvailableYearsLoadMeta {
strategy: 'cache' | 'native' | 'hybrid'
nativeElapsedMs: number
scanElapsedMs: number
totalElapsedMs: number
switched: boolean
nativeTimedOut: boolean
statusText: string
}
class AnnualReportService { class AnnualReportService {
private readonly availableYearsCacheTtlMs = 10 * 60 * 1000 private readonly availableYearsCacheTtlMs = 10 * 60 * 1000
private readonly availableYearsScanConcurrency = 4 private readonly availableYearsScanConcurrency = 4
@@ -596,46 +618,222 @@ class AnnualReportService {
dbPath: string dbPath: string
decryptKey: string decryptKey: string
wxid: string wxid: string
onProgress?: (years: number[]) => void onProgress?: (payload: AvailableYearsLoadProgress) => void
shouldCancel?: () => boolean shouldCancel?: () => boolean
}): Promise<{ success: boolean; data?: number[]; error?: string }> { nativeTimeoutMs?: number
}): Promise<{ success: boolean; data?: number[]; error?: string; meta?: AvailableYearsLoadMeta }> {
try { try {
const isCancelled = () => params.shouldCancel?.() === true const isCancelled = () => params.shouldCancel?.() === true
const totalStartedAt = Date.now()
let nativeElapsedMs = 0
let scanElapsedMs = 0
let switched = false
let nativeTimedOut = false
let latestYears: number[] = []
const emitProgress = (payload: {
years?: number[]
strategy: 'cache' | 'native' | 'hybrid'
phase: 'cache' | 'native' | 'scan'
statusText: string
switched?: boolean
nativeTimedOut?: boolean
}) => {
if (!params.onProgress) return
if (Array.isArray(payload.years)) latestYears = payload.years
params.onProgress({
years: latestYears,
strategy: payload.strategy,
phase: payload.phase,
statusText: payload.statusText,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched: payload.switched ?? switched,
nativeTimedOut: payload.nativeTimedOut ?? nativeTimedOut
})
}
const buildMeta = (
strategy: 'cache' | 'native' | 'hybrid',
statusText: string
): AvailableYearsLoadMeta => ({
strategy,
nativeElapsedMs,
scanElapsedMs,
totalElapsedMs: Date.now() - totalStartedAt,
switched,
nativeTimedOut,
statusText
})
const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid) const conn = await this.ensureConnectedWithConfig(params.dbPath, params.decryptKey, params.wxid)
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error, meta: buildMeta('hybrid', '连接数据库失败') }
if (isCancelled()) return { success: false, error: '已取消加载年份数据' } if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid) const cacheKey = this.buildAvailableYearsCacheKey(params.dbPath, conn.cleanedWxid)
const cached = this.getCachedAvailableYears(cacheKey) const cached = this.getCachedAvailableYears(cacheKey)
if (cached) { if (cached) {
params.onProgress?.(cached) latestYears = cached
return { success: true, data: cached } emitProgress({
years: cached,
strategy: 'cache',
phase: 'cache',
statusText: '命中缓存,已快速加载年份数据'
})
return {
success: true,
data: cached,
meta: buildMeta('cache', '命中缓存,已快速加载年份数据')
}
} }
const sessionIds = await this.getPrivateSessions(conn.cleanedWxid) const sessionIds = await this.getPrivateSessions(conn.cleanedWxid)
if (sessionIds.length === 0) { if (sessionIds.length === 0) {
return { success: false, error: '未找到消息会话' } return { success: false, error: '未找到消息会话', meta: buildMeta('hybrid', '未找到消息会话') }
} }
if (isCancelled()) return { success: false, error: '已取消加载年份数据' } if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
const nativeTimeoutMs = Math.max(1000, Math.floor(params.nativeTimeoutMs || 5000))
const nativeStartedAt = Date.now()
let nativeTicker: ReturnType<typeof setInterval> | null = null
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
nativeTicker = setInterval(() => {
nativeElapsedMs = Date.now() - nativeStartedAt
emitProgress({
strategy: 'native',
phase: 'native',
statusText: '正在使用原生快速模式加载年份...'
})
}, 120)
const nativeRace = await Promise.race([
wcdbService.getAvailableYears(sessionIds)
.then((result) => ({ kind: 'result' as const, result }))
.catch((error) => ({ kind: 'error' as const, error: String(error) })),
new Promise<{ kind: 'timeout' }>((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), nativeTimeoutMs))
])
if (nativeTicker) {
clearInterval(nativeTicker)
nativeTicker = null
}
nativeElapsedMs = Math.max(nativeElapsedMs, Date.now() - nativeStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
if (nativeRace.kind === 'result' && nativeRace.result.success && Array.isArray(nativeRace.result.data) && nativeRace.result.data.length > 0) {
const years = this.normalizeAvailableYears(nativeRace.result.data)
latestYears = years
this.setCachedAvailableYears(cacheKey, years)
emitProgress({
years,
strategy: 'native',
phase: 'native',
statusText: '原生快速模式加载完成'
})
return {
success: true,
data: years,
meta: buildMeta('native', '原生快速模式加载完成')
}
}
switched = true
nativeTimedOut = nativeRace.kind === 'timeout'
emitProgress({
strategy: 'hybrid',
phase: 'native',
statusText: nativeTimedOut
? '原生快速模式超时,已自动切换到扫表兼容模式...'
: '原生快速模式不可用,已自动切换到扫表兼容模式...',
switched: true,
nativeTimedOut
})
const scanStartedAt = Date.now()
let scanTicker: ReturnType<typeof setInterval> | null = null
scanTicker = setInterval(() => {
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
}, 120)
let years = await this.getAvailableYearsByTableScan(sessionIds, { let years = await this.getAvailableYearsByTableScan(sessionIds, {
onProgress: params.onProgress, onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: nativeTimedOut
? '原生已超时,正在使用扫表兼容模式加载年份...'
: '正在使用扫表兼容模式加载年份...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel shouldCancel: params.shouldCancel
}) })
if (isCancelled()) return { success: false, error: '已取消加载年份数据' }
if (isCancelled()) {
if (scanTicker) clearInterval(scanTicker)
return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
}
if (years.length === 0) { if (years.length === 0) {
// 扫表失败时,再降级到游标首尾扫描,保证兼容性。
years = await this.getAvailableYearsByEdgeScan(sessionIds, { years = await this.getAvailableYearsByEdgeScan(sessionIds, {
onProgress: params.onProgress, onProgress: (items) => {
latestYears = items
scanElapsedMs = Date.now() - scanStartedAt
emitProgress({
years: items,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表结果为空,正在执行游标兜底扫描...',
switched: true,
nativeTimedOut
})
},
shouldCancel: params.shouldCancel shouldCancel: params.shouldCancel
}) })
} }
if (isCancelled()) return { success: false, error: '已取消加载年份数据' } if (scanTicker) {
clearInterval(scanTicker)
scanTicker = null
}
scanElapsedMs = Math.max(scanElapsedMs, Date.now() - scanStartedAt)
if (isCancelled()) return { success: false, error: '已取消加载年份数据', meta: buildMeta('hybrid', '已取消加载年份数据') }
this.setCachedAvailableYears(cacheKey, years) this.setCachedAvailableYears(cacheKey, years)
params.onProgress?.(years) latestYears = years
return { success: true, data: years } emitProgress({
years,
strategy: 'hybrid',
phase: 'scan',
statusText: '扫表兼容模式加载完成',
switched: true,
nativeTimedOut
})
return {
success: true,
data: years,
meta: buildMeta('hybrid', '扫表兼容模式加载完成')
}
} catch (e) { } catch (e) {
return { success: false, error: String(e) } return { success: false, error: String(e), meta: { strategy: 'hybrid', nativeElapsedMs: 0, scanElapsedMs: 0, totalElapsedMs: 0, switched: false, nativeTimedOut: false, statusText: '加载年度数据失败' } }
} }
} }

View File

@@ -34,6 +34,40 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.load-telemetry {
width: min(760px, 100%);
padding: 12px 14px;
margin: 0 0 28px;
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent);
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
text-align: left;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
p {
margin: 4px 0;
}
.label {
color: var(--text-tertiary);
}
}
.load-telemetry.loading {
border-color: color-mix(in srgb, var(--primary) 30%, var(--border-color));
}
.load-telemetry.complete {
border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color));
}
.load-telemetry.compact {
margin: 12px 0 0;
width: min(560px, 100%);
}
.report-sections { .report-sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -4,6 +4,20 @@ import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss' import './AnnualReportPage.scss'
type YearOption = number | 'all' type YearOption = number | 'all'
type YearsLoadPayload = {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
const formatLoadElapsed = (ms: number) => { const formatLoadElapsed = (ms: number) => {
const totalSeconds = Math.max(0, ms) / 1000 const totalSeconds = Math.max(0, ms) / 1000
@@ -21,37 +35,36 @@ function AnnualReportPage() {
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false) const [isLoadingMoreYears, setIsLoadingMoreYears] = useState(false)
const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false) const [hasYearsLoadFinished, setHasYearsLoadFinished] = useState(false)
const [loadElapsedMs, setLoadElapsedMs] = useState(0) const [loadStrategy, setLoadStrategy] = useState<'cache' | 'native' | 'hybrid'>('native')
const [loadPhase, setLoadPhase] = useState<'cache' | 'native' | 'scan' | 'done'>('native')
const [loadStatusText, setLoadStatusText] = useState('准备加载年份数据...')
const [nativeElapsedMs, setNativeElapsedMs] = useState(0)
const [scanElapsedMs, setScanElapsedMs] = useState(0)
const [totalElapsedMs, setTotalElapsedMs] = useState(0)
const [hasSwitchedStrategy, setHasSwitchedStrategy] = useState(false)
const [nativeTimedOut, setNativeTimedOut] = useState(false)
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
let disposed = false let disposed = false
let taskId = '' let taskId = ''
const loadStartedAt = Date.now()
let ticker: ReturnType<typeof setInterval> | null = null
const startTicker = () => { const applyLoadPayload = (payload: YearsLoadPayload) => {
setLoadElapsedMs(0) if (payload.strategy) setLoadStrategy(payload.strategy)
if (ticker) clearInterval(ticker) if (payload.phase) setLoadPhase(payload.phase)
ticker = setInterval(() => { if (typeof payload.statusText === 'string' && payload.statusText) setLoadStatusText(payload.statusText)
if (disposed) return if (typeof payload.nativeElapsedMs === 'number' && Number.isFinite(payload.nativeElapsedMs)) {
setLoadElapsedMs(Date.now() - loadStartedAt) setNativeElapsedMs(Math.max(0, payload.nativeElapsedMs))
}, 100)
}
const stopTicker = () => {
setLoadElapsedMs(Date.now() - loadStartedAt)
if (ticker) {
clearInterval(ticker)
ticker = null
} }
} if (typeof payload.scanElapsedMs === 'number' && Number.isFinite(payload.scanElapsedMs)) {
setScanElapsedMs(Math.max(0, payload.scanElapsedMs))
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => { }
if (disposed) return if (typeof payload.totalElapsedMs === 'number' && Number.isFinite(payload.totalElapsedMs)) {
if (taskId && payload.taskId !== taskId) return setTotalElapsedMs(Math.max(0, payload.totalElapsedMs))
if (!taskId) taskId = payload.taskId }
if (typeof payload.switched === 'boolean') setHasSwitchedStrategy(payload.switched)
if (typeof payload.nativeTimedOut === 'boolean') setNativeTimedOut(payload.nativeTimedOut)
const years = Array.isArray(payload.years) ? payload.years : [] const years = Array.isArray(payload.years) ? payload.years : []
if (years.length > 0) { if (years.length > 0) {
@@ -66,7 +79,6 @@ function AnnualReportPage() {
if (typeof prev === 'number' && years.includes(prev)) return prev if (typeof prev === 'number' && years.includes(prev)) return prev
return years[0] return years[0]
}) })
// 只要有首批年份可选,就不再阻塞整个页面。
setIsLoading(false) setIsLoading(false)
} }
@@ -78,35 +90,50 @@ function AnnualReportPage() {
setIsLoading(false) setIsLoading(false)
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)
setHasYearsLoadFinished(true) setHasYearsLoadFinished(true)
stopTicker() setLoadPhase('done')
} else { } else {
setIsLoadingMoreYears(true) setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false) setHasYearsLoadFinished(false)
} }
}
const stopListen = window.electronAPI.annualReport.onAvailableYearsProgress((payload) => {
if (disposed) return
if (taskId && payload.taskId !== taskId) return
if (!taskId) taskId = payload.taskId
applyLoadPayload(payload)
}) })
const startLoad = async () => { const startLoad = async () => {
setIsLoading(true) setIsLoading(true)
setIsLoadingMoreYears(true) setIsLoadingMoreYears(true)
setHasYearsLoadFinished(false) setHasYearsLoadFinished(false)
setLoadStrategy('native')
setLoadPhase('native')
setLoadStatusText('准备使用原生快速模式加载年份...')
setNativeElapsedMs(0)
setScanElapsedMs(0)
setTotalElapsedMs(0)
setHasSwitchedStrategy(false)
setNativeTimedOut(false)
setLoadError(null) setLoadError(null)
startTicker()
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) {
setLoadError(startResult.error || '加载年度数据失败') setLoadError(startResult.error || '加载年度数据失败')
setIsLoading(false) setIsLoading(false)
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)
stopTicker()
return return
} }
taskId = startResult.taskId taskId = startResult.taskId
if (startResult.snapshot) {
applyLoadPayload(startResult.snapshot)
}
} catch (e) { } catch (e) {
console.error(e) console.error(e)
setLoadError(String(e)) setLoadError(String(e))
setIsLoading(false) setIsLoading(false)
setIsLoadingMoreYears(false) setIsLoadingMoreYears(false)
stopTicker()
} }
} }
@@ -114,11 +141,7 @@ function AnnualReportPage() {
return () => { return () => {
disposed = true disposed = true
stopTicker()
stopListen() stopListen()
if (taskId) {
void window.electronAPI.annualReport.cancelAvailableYearsLoad(taskId)
}
} }
}, []) }, [])
@@ -146,7 +169,15 @@ function AnnualReportPage() {
<div className="annual-report-page"> <div className="annual-report-page">
<Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} /> <Loader2 size={32} className="spin" style={{ color: 'var(--text-tertiary)' }} />
<p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p> <p style={{ color: 'var(--text-tertiary)', marginTop: 16 }}>...</p>
<p style={{ color: 'var(--text-tertiary)', marginTop: 8 }}> {formatLoadElapsed(loadElapsedMs)}</p> <div className="load-telemetry compact">
<p><span className="label"></span>{getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })}</p>
<p><span className="label"></span>{loadStatusText || '正在加载年份数据...'}</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
</div> </div>
) )
} }
@@ -174,6 +205,7 @@ function AnnualReportPage() {
const loadedYearCount = availableYears.length const loadedYearCount = availableYears.length
const isYearStatusComplete = hasYearsLoadFinished const isYearStatusComplete = hasYearsLoadFinished
const strategyLabel = getStrategyLabel({ loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut })
const renderYearLoadStatus = () => ( const renderYearLoadStatus = () => (
<div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}> <div className={`year-load-status ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? ( {isYearStatusComplete ? (
@@ -194,15 +226,27 @@ function AnnualReportPage() {
{loadedYearCount > 0 && ( {loadedYearCount > 0 && (
<p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}> <p className={`page-desc load-summary ${isYearStatusComplete ? 'complete' : 'loading'}`}>
{isYearStatusComplete ? ( {isYearStatusComplete ? (
<> {loadedYearCount} {formatLoadElapsed(loadElapsedMs)}</> <> {loadedYearCount} {formatLoadElapsed(totalElapsedMs)}</>
) : ( ) : (
<> <>
{loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span> {loadedYearCount} <span className="dot-ellipsis" aria-hidden="true">...</span>
{formatLoadElapsed(loadElapsedMs)} {formatLoadElapsed(totalElapsedMs)}
</> </>
)} )}
</p> </p>
)} )}
<div className={`load-telemetry ${isYearStatusComplete ? 'complete' : 'loading'}`}>
<p><span className="label"></span>{strategyLabel}</p>
<p>
<span className="label"></span>
{loadStatusText || (isYearStatusComplete ? '全部年份已加载完毕' : '正在加载年份数据...')}
</p>
<p>
<span className="label"></span>{formatLoadElapsed(nativeElapsedMs)}{nativeTimedOut ? '(超时)' : ''} {' '}
<span className="label"></span>{formatLoadElapsed(scanElapsedMs)} {' '}
<span className="label"></span>{formatLoadElapsed(totalElapsedMs)}
</p>
</div>
<div className="report-sections"> <div className="report-sections">
<section className="report-section"> <section className="report-section">
@@ -291,4 +335,23 @@ function AnnualReportPage() {
) )
} }
function getStrategyLabel(params: {
loadStrategy: 'cache' | 'native' | 'hybrid'
loadPhase: 'cache' | 'native' | 'scan' | 'done'
hasYearsLoadFinished: boolean
hasSwitchedStrategy: boolean
nativeTimedOut: boolean
}): string {
const { loadStrategy, loadPhase, hasYearsLoadFinished, hasSwitchedStrategy, nativeTimedOut } = params
if (loadStrategy === 'cache') return '缓存模式(快速)'
if (hasYearsLoadFinished) {
if (loadStrategy === 'native') return '原生快速模式'
if (hasSwitchedStrategy || nativeTimedOut) return '混合策略(原生→扫表)'
return '扫表兼容模式'
}
if (loadPhase === 'native') return '原生快速模式(优先)'
if (loadPhase === 'scan') return '扫表兼容模式(回退)'
return '混合策略'
}
export default AnnualReportPage export default AnnualReportPage

View File

@@ -486,6 +486,21 @@ export interface ElectronAPI {
startAvailableYearsLoad: () => Promise<{ startAvailableYearsLoad: () => Promise<{
success: boolean success: boolean
taskId?: string taskId?: string
reused?: boolean
snapshot?: {
years?: number[]
done: boolean
error?: string
canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}
error?: string error?: string
}> }>
cancelAvailableYearsLoad: (taskId: string) => Promise<{ cancelAvailableYearsLoad: (taskId: string) => Promise<{
@@ -582,6 +597,14 @@ export interface ElectronAPI {
done: boolean done: boolean
error?: string error?: string
canceled?: boolean canceled?: boolean
strategy?: 'cache' | 'native' | 'hybrid'
phase?: 'cache' | 'native' | 'scan' | 'done'
statusText?: string
nativeElapsedMs?: number
scanElapsedMs?: number
totalElapsedMs?: number
switched?: boolean
nativeTimedOut?: boolean
}) => void) => () => void }) => void) => () => void
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
} }