mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
perf(export): cache counts and speed sns/session stats
This commit is contained in:
@@ -1032,6 +1032,10 @@ function registerIpcHandlers() {
|
|||||||
return snsService.getExportStats()
|
return snsService.getExportStats()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('sns:getExportStatsFast', async () => {
|
||||||
|
return snsService.getExportStatsFast()
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
ipcMain.handle('sns:debugResource', async (_, url: string) => {
|
||||||
return snsService.debugResource(url)
|
return snsService.debugResource(url)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
|
||||||
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
|
||||||
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
|
||||||
|
getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'),
|
||||||
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
getExportStats: () => ipcRenderer.invoke('sns:getExportStats'),
|
||||||
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ class ChatService {
|
|||||||
// 缓存会话表信息,避免每次查询
|
// 缓存会话表信息,避免每次查询
|
||||||
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
private sessionTablesCache = new Map<string, Array<{ tableName: string; dbPath: string }>>()
|
||||||
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
private readonly sessionTablesCacheTtl = 300000 // 5分钟
|
||||||
|
private sessionMessageCountCache = new Map<string, { count: number; updatedAt: number }>()
|
||||||
|
private sessionMessageCountCacheScope = ''
|
||||||
|
private readonly sessionMessageCountCacheTtlMs = 10 * 60 * 1000
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -795,13 +798,35 @@ class ChatService {
|
|||||||
return { success: true, counts: {} }
|
return { success: true, counts: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refreshSessionMessageCountCacheScope()
|
||||||
const counts: Record<string, number> = {}
|
const counts: Record<string, number> = {}
|
||||||
await this.forEachWithConcurrency(normalizedSessionIds, 8, async (sessionId) => {
|
const now = Date.now()
|
||||||
|
const pendingSessionIds: string[] = []
|
||||||
|
|
||||||
|
for (const sessionId of normalizedSessionIds) {
|
||||||
|
const cached = this.sessionMessageCountCache.get(sessionId)
|
||||||
|
if (cached && now - cached.updatedAt <= this.sessionMessageCountCacheTtlMs) {
|
||||||
|
counts[sessionId] = cached.count
|
||||||
|
} else {
|
||||||
|
pendingSessionIds.push(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.forEachWithConcurrency(pendingSessionIds, 16, async (sessionId) => {
|
||||||
try {
|
try {
|
||||||
const result = await wcdbService.getMessageCount(sessionId)
|
const result = await wcdbService.getMessageCount(sessionId)
|
||||||
counts[sessionId] = result.success && typeof result.count === 'number' ? result.count : 0
|
const nextCount = result.success && typeof result.count === 'number' ? result.count : 0
|
||||||
|
counts[sessionId] = nextCount
|
||||||
|
this.sessionMessageCountCache.set(sessionId, {
|
||||||
|
count: nextCount,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
counts[sessionId] = 0
|
counts[sessionId] = 0
|
||||||
|
this.sessionMessageCountCache.set(sessionId, {
|
||||||
|
count: 0,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1455,6 +1480,15 @@ class ChatService {
|
|||||||
await Promise.all(runners)
|
await Promise.all(runners)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshSessionMessageCountCacheScope(): void {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '')
|
||||||
|
const myWxid = String(this.configService.get('myWxid') || '')
|
||||||
|
const scope = `${dbPath}::${myWxid}`
|
||||||
|
if (scope === this.sessionMessageCountCacheScope) return
|
||||||
|
this.sessionMessageCountCacheScope = scope
|
||||||
|
this.sessionMessageCountCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
private async collectSessionExportStats(
|
private async collectSessionExportStats(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
selfIdentitySet: Set<string>
|
selfIdentitySet: Set<string>
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ class SnsService {
|
|||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
private contactCache: ContactCacheService
|
private contactCache: ContactCacheService
|
||||||
private imageCache = new Map<string, string>()
|
private imageCache = new Map<string, string>()
|
||||||
|
private exportStatsCache: { totalPosts: number; totalFriends: number; updatedAt: number } | null = null
|
||||||
|
private readonly exportStatsCacheTtlMs = 5 * 60 * 1000
|
||||||
|
private lastTimelineFallbackAt = 0
|
||||||
|
private readonly timelineFallbackCooldownMs = 3 * 60 * 1000
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -403,8 +407,7 @@ class SnsService {
|
|||||||
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExportStats(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> {
|
private async getExportStatsFromTableCount(): Promise<{ totalPosts: number; totalFriends: number }> {
|
||||||
try {
|
|
||||||
let totalPosts = 0
|
let totalPosts = 0
|
||||||
let totalFriends = 0
|
let totalFriends = 0
|
||||||
|
|
||||||
@@ -433,8 +436,37 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 某些环境下 SnsTimeLine 统计查询会返回 0,这里回退到与导出同源的 timeline 接口统计。
|
return { totalPosts, totalFriends }
|
||||||
if (totalPosts <= 0 || totalFriends <= 0) {
|
}
|
||||||
|
|
||||||
|
async getExportStats(options?: {
|
||||||
|
allowTimelineFallback?: boolean
|
||||||
|
preferCache?: boolean
|
||||||
|
}): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> {
|
||||||
|
const allowTimelineFallback = options?.allowTimelineFallback ?? true
|
||||||
|
const preferCache = options?.preferCache ?? false
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (preferCache && this.exportStatsCache && now - this.exportStatsCache.updatedAt <= this.exportStatsCacheTtlMs) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalPosts: this.exportStatsCache.totalPosts,
|
||||||
|
totalFriends: this.exportStatsCache.totalFriends
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let { totalPosts, totalFriends } = await this.getExportStatsFromTableCount()
|
||||||
|
|
||||||
|
// 某些环境下 SnsTimeLine 统计查询会返回 0,这里在允许时回退到与导出同源的 timeline 接口统计。
|
||||||
|
if (
|
||||||
|
allowTimelineFallback &&
|
||||||
|
(totalPosts <= 0 || totalFriends <= 0) &&
|
||||||
|
now - this.lastTimelineFallbackAt >= this.timelineFallbackCooldownMs
|
||||||
|
) {
|
||||||
|
this.lastTimelineFallbackAt = now
|
||||||
const timelineStats = await this.getExportStatsFromTimeline()
|
const timelineStats = await this.getExportStatsFromTimeline()
|
||||||
if (timelineStats.totalPosts > 0) {
|
if (timelineStats.totalPosts > 0) {
|
||||||
totalPosts = timelineStats.totalPosts
|
totalPosts = timelineStats.totalPosts
|
||||||
@@ -444,12 +476,34 @@ class SnsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.exportStatsCache = {
|
||||||
|
totalPosts,
|
||||||
|
totalFriends,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true, data: { totalPosts, totalFriends } }
|
return { success: true, data: { totalPosts, totalFriends } }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (this.exportStatsCache) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalPosts: this.exportStatsCache.totalPosts,
|
||||||
|
totalFriends: this.exportStatsCache.totalFriends
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getExportStatsFast(): Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }> {
|
||||||
|
return this.getExportStats({
|
||||||
|
allowTimelineFallback: false,
|
||||||
|
preferCache: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 安装朋友圈删除拦截
|
// 安装朋友圈删除拦截
|
||||||
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> {
|
||||||
return wcdbService.installSnsBlockDeleteTrigger()
|
return wcdbService.installSnsBlockDeleteTrigger()
|
||||||
|
|||||||
@@ -237,13 +237,15 @@ const timestampOrDash = (timestamp?: number): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const createTaskId = (): string => `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
const MESSAGE_COUNT_VIEWPORT_PREFETCH = 220
|
const MESSAGE_COUNT_VIEWPORT_PREFETCH = 120
|
||||||
const MESSAGE_COUNT_BACKGROUND_BATCH = 180
|
const MESSAGE_COUNT_BACKGROUND_BATCH = 90
|
||||||
const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 100
|
const MESSAGE_COUNT_BACKGROUND_INTERVAL_MS = 90
|
||||||
const METRICS_VIEWPORT_PREFETCH = 90
|
const METRICS_VIEWPORT_PREFETCH = 90
|
||||||
const METRICS_BACKGROUND_BATCH = 40
|
const METRICS_BACKGROUND_BATCH = 40
|
||||||
const METRICS_BACKGROUND_INTERVAL_MS = 220
|
const METRICS_BACKGROUND_INTERVAL_MS = 220
|
||||||
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
const CONTACT_ENRICH_TIMEOUT_MS = 7000
|
||||||
|
const EXPORT_SESSION_COUNT_CACHE_STALE_MS = 48 * 60 * 60 * 1000
|
||||||
|
const EXPORT_SNS_STATS_CACHE_STALE_MS = 12 * 60 * 60 * 1000
|
||||||
|
|
||||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
@@ -371,11 +373,13 @@ function ExportPage() {
|
|||||||
totalPosts: 0,
|
totalPosts: 0,
|
||||||
totalFriends: 0
|
totalFriends: 0
|
||||||
})
|
})
|
||||||
|
const [hasSeededSnsStats, setHasSeededSnsStats] = useState(false)
|
||||||
const [nowTick, setNowTick] = useState(Date.now())
|
const [nowTick, setNowTick] = useState(Date.now())
|
||||||
|
|
||||||
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
|
const progressUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||||
const runningTaskIdRef = useRef<string | null>(null)
|
const runningTaskIdRef = useRef<string | null>(null)
|
||||||
const tasksRef = useRef<ExportTask[]>([])
|
const tasksRef = useRef<ExportTask[]>([])
|
||||||
|
const hasSeededSnsStatsRef = useRef(false)
|
||||||
const sessionMessageCountsRef = useRef<Record<string, number>>({})
|
const sessionMessageCountsRef = useRef<Record<string, number>>({})
|
||||||
const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({})
|
const sessionMetricsRef = useRef<Record<string, SessionMetrics>>({})
|
||||||
const sessionLoadTokenRef = useRef(0)
|
const sessionLoadTokenRef = useRef(0)
|
||||||
@@ -383,11 +387,18 @@ function ExportPage() {
|
|||||||
const loadingMetricsRef = useRef<Set<string>>(new Set())
|
const loadingMetricsRef = useRef<Set<string>>(new Set())
|
||||||
const preselectAppliedRef = useRef(false)
|
const preselectAppliedRef = useRef(false)
|
||||||
const visibleSessionsRef = useRef<SessionRow[]>([])
|
const visibleSessionsRef = useRef<SessionRow[]>([])
|
||||||
|
const exportCacheScopeRef = useRef('default')
|
||||||
|
const exportCacheScopeReadyRef = useRef(false)
|
||||||
|
const persistSessionCountTimerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tasksRef.current = tasks
|
tasksRef.current = tasks
|
||||||
}, [tasks])
|
}, [tasks])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
hasSeededSnsStatsRef.current = hasSeededSnsStats
|
||||||
|
}, [hasSeededSnsStats])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionMessageCountsRef.current = sessionMessageCounts
|
sessionMessageCountsRef.current = sessionMessageCounts
|
||||||
}, [sessionMessageCounts])
|
}, [sessionMessageCounts])
|
||||||
@@ -396,6 +407,30 @@ function ExportPage() {
|
|||||||
sessionMetricsRef.current = sessionMetrics
|
sessionMetricsRef.current = sessionMetrics
|
||||||
}, [sessionMetrics])
|
}, [sessionMetrics])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (persistSessionCountTimerRef.current) {
|
||||||
|
window.clearTimeout(persistSessionCountTimerRef.current)
|
||||||
|
persistSessionCountTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBaseConfigLoading || !exportCacheScopeReadyRef.current) return
|
||||||
|
|
||||||
|
const countSize = Object.keys(sessionMessageCounts).length
|
||||||
|
if (countSize === 0) return
|
||||||
|
|
||||||
|
persistSessionCountTimerRef.current = window.setTimeout(() => {
|
||||||
|
void configService.setExportSessionMessageCountCache(exportCacheScopeRef.current, sessionMessageCounts)
|
||||||
|
persistSessionCountTimerRef.current = null
|
||||||
|
}, 900)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (persistSessionCountTimerRef.current) {
|
||||||
|
window.clearTimeout(persistSessionCountTimerRef.current)
|
||||||
|
persistSessionCountTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [sessionMessageCounts, isBaseConfigLoading])
|
||||||
|
|
||||||
const preselectSessionIds = useMemo(() => {
|
const preselectSessionIds = useMemo(() => {
|
||||||
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
|
||||||
const rawList = Array.isArray(state?.preselectSessionIds)
|
const rawList = Array.isArray(state?.preselectSessionIds)
|
||||||
@@ -416,7 +451,7 @@ function ExportPage() {
|
|||||||
const loadBaseConfig = useCallback(async () => {
|
const loadBaseConfig = useCallback(async () => {
|
||||||
setIsBaseConfigLoading(true)
|
setIsBaseConfigLoading(true)
|
||||||
try {
|
try {
|
||||||
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount] = await Promise.all([
|
const [savedPath, savedFormat, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedTxtColumns, savedConcurrency, savedWriteLayout, savedSessionMap, savedContentMap, savedSnsPostCount, myWxid, dbPath] = await Promise.all([
|
||||||
configService.getExportPath(),
|
configService.getExportPath(),
|
||||||
configService.getExportDefaultFormat(),
|
configService.getExportDefaultFormat(),
|
||||||
configService.getExportDefaultMedia(),
|
configService.getExportDefaultMedia(),
|
||||||
@@ -427,7 +462,17 @@ function ExportPage() {
|
|||||||
configService.getExportWriteLayout(),
|
configService.getExportWriteLayout(),
|
||||||
configService.getExportLastSessionRunMap(),
|
configService.getExportLastSessionRunMap(),
|
||||||
configService.getExportLastContentRunMap(),
|
configService.getExportLastContentRunMap(),
|
||||||
configService.getExportLastSnsPostCount()
|
configService.getExportLastSnsPostCount(),
|
||||||
|
configService.getMyWxid(),
|
||||||
|
configService.getDbPath()
|
||||||
|
])
|
||||||
|
const exportCacheScope = `${dbPath || ''}::${myWxid || ''}` || 'default'
|
||||||
|
exportCacheScopeRef.current = exportCacheScope
|
||||||
|
exportCacheScopeReadyRef.current = true
|
||||||
|
|
||||||
|
const [cachedSessionCountMap, cachedSnsStats] = await Promise.all([
|
||||||
|
configService.getExportSessionMessageCountCache(exportCacheScope),
|
||||||
|
configService.getExportSnsStatsCache(exportCacheScope)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (savedPath) {
|
if (savedPath) {
|
||||||
@@ -442,6 +487,19 @@ function ExportPage() {
|
|||||||
setLastExportByContent(savedContentMap)
|
setLastExportByContent(savedContentMap)
|
||||||
setLastSnsExportPostCount(savedSnsPostCount)
|
setLastSnsExportPostCount(savedSnsPostCount)
|
||||||
|
|
||||||
|
if (cachedSessionCountMap && Date.now() - cachedSessionCountMap.updatedAt <= EXPORT_SESSION_COUNT_CACHE_STALE_MS) {
|
||||||
|
setSessionMessageCounts(cachedSessionCountMap.counts || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedSnsStats && Date.now() - cachedSnsStats.updatedAt <= EXPORT_SNS_STATS_CACHE_STALE_MS) {
|
||||||
|
setSnsStats({
|
||||||
|
totalPosts: cachedSnsStats.totalPosts || 0,
|
||||||
|
totalFriends: cachedSnsStats.totalFriends || 0
|
||||||
|
})
|
||||||
|
hasSeededSnsStatsRef.current = true
|
||||||
|
setHasSeededSnsStats(true)
|
||||||
|
}
|
||||||
|
|
||||||
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
const txtColumns = savedTxtColumns && savedTxtColumns.length > 0 ? savedTxtColumns : defaultTxtColumns
|
||||||
setOptions(prev => ({
|
setOptions(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -473,21 +531,53 @@ function ExportPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadSnsStats = useCallback(async () => {
|
const loadSnsStats = useCallback(async (options?: { full?: boolean; silent?: boolean }) => {
|
||||||
|
if (!options?.silent) {
|
||||||
setIsSnsStatsLoading(true)
|
setIsSnsStatsLoading(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyStats = async (next: { totalPosts: number; totalFriends: number } | null) => {
|
||||||
|
if (!next) return
|
||||||
|
const normalized = {
|
||||||
|
totalPosts: Number.isFinite(next.totalPosts) ? Math.max(0, Math.floor(next.totalPosts)) : 0,
|
||||||
|
totalFriends: Number.isFinite(next.totalFriends) ? Math.max(0, Math.floor(next.totalFriends)) : 0
|
||||||
|
}
|
||||||
|
setSnsStats(normalized)
|
||||||
|
hasSeededSnsStatsRef.current = true
|
||||||
|
setHasSeededSnsStats(true)
|
||||||
|
if (exportCacheScopeReadyRef.current) {
|
||||||
|
await configService.setExportSnsStatsCache(exportCacheScopeRef.current, normalized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.sns.getExportStats()
|
const fastResult = await withTimeout(window.electronAPI.sns.getExportStatsFast(), 2200)
|
||||||
if (result.success && result.data) {
|
if (fastResult?.success && fastResult.data) {
|
||||||
setSnsStats({
|
const fastStats = {
|
||||||
|
totalPosts: fastResult.data.totalPosts || 0,
|
||||||
|
totalFriends: fastResult.data.totalFriends || 0
|
||||||
|
}
|
||||||
|
if (fastStats.totalPosts > 0 || hasSeededSnsStatsRef.current) {
|
||||||
|
await applyStats(fastStats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.full) {
|
||||||
|
const result = await withTimeout(window.electronAPI.sns.getExportStats(), 9000)
|
||||||
|
if (result?.success && result.data) {
|
||||||
|
await applyStats({
|
||||||
totalPosts: result.data.totalPosts || 0,
|
totalPosts: result.data.totalPosts || 0,
|
||||||
totalFriends: result.data.totalFriends || 0
|
totalFriends: result.data.totalFriends || 0
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载朋友圈导出统计失败:', error)
|
console.error('加载朋友圈导出统计失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (!options?.silent) {
|
||||||
setIsSnsStatsLoading(false)
|
setIsSnsStatsLoading(false)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
@@ -497,9 +587,7 @@ function ExportPage() {
|
|||||||
setIsSessionEnriching(false)
|
setIsSessionEnriching(false)
|
||||||
loadingMessageCountsRef.current.clear()
|
loadingMessageCountsRef.current.clear()
|
||||||
loadingMetricsRef.current.clear()
|
loadingMetricsRef.current.clear()
|
||||||
sessionMessageCountsRef.current = {}
|
|
||||||
sessionMetricsRef.current = {}
|
sessionMetricsRef.current = {}
|
||||||
setSessionMessageCounts({})
|
|
||||||
setSessionMetrics({})
|
setSessionMetrics({})
|
||||||
|
|
||||||
const isStale = () => sessionLoadTokenRef.current !== loadToken
|
const isStale = () => sessionLoadTokenRef.current !== loadToken
|
||||||
@@ -530,6 +618,16 @@ function ExportPage() {
|
|||||||
|
|
||||||
if (isStale()) return
|
if (isStale()) return
|
||||||
setSessions(baseSessions)
|
setSessions(baseSessions)
|
||||||
|
setSessionMessageCounts(prev => {
|
||||||
|
const next: Record<string, number> = {}
|
||||||
|
for (const session of baseSessions) {
|
||||||
|
const count = prev[session.username]
|
||||||
|
if (typeof count === 'number') {
|
||||||
|
next[session.username] = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
|
|
||||||
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
|
// 后台补齐联系人字段(昵称、头像、类型),不阻塞首屏会话列表渲染。
|
||||||
@@ -602,8 +700,8 @@ function ExportPage() {
|
|||||||
|
|
||||||
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
// 朋友圈统计延后一点加载,避免与首屏会话初始化抢占。
|
||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
void loadSnsStats()
|
void loadSnsStats({ full: true })
|
||||||
}, 180)
|
}, 120)
|
||||||
|
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
}, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats])
|
}, [loadTabCounts, loadBaseConfig, loadSessions, loadSnsStats])
|
||||||
@@ -666,41 +764,43 @@ function ExportPage() {
|
|||||||
session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username)
|
session => currentCounts[session.username] === undefined && !loadingMessageCountsRef.current.has(session.username)
|
||||||
)
|
)
|
||||||
if (pending.length === 0) return
|
if (pending.length === 0) return
|
||||||
|
|
||||||
const updates: Record<string, number> = {}
|
|
||||||
for (const session of pending) {
|
for (const session of pending) {
|
||||||
loadingMessageCountsRef.current.add(session.username)
|
loadingMessageCountsRef.current.add(session.username)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const batchSize = 220
|
const batchSize = pending.length > 100 ? 48 : 28
|
||||||
for (let i = 0; i < pending.length; i += batchSize) {
|
for (let i = 0; i < pending.length; i += batchSize) {
|
||||||
if (loadTokenAtStart !== sessionLoadTokenRef.current) return
|
if (loadTokenAtStart !== sessionLoadTokenRef.current) return
|
||||||
const chunk = pending.slice(i, i + batchSize)
|
const chunk = pending.slice(i, i + batchSize)
|
||||||
const ids = chunk.map(session => session.username)
|
const ids = chunk.map(session => session.username)
|
||||||
|
const chunkUpdates: Record<string, number> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.chat.getSessionMessageCounts(ids)
|
const result = await withTimeout(window.electronAPI.chat.getSessionMessageCounts(ids), 10000)
|
||||||
|
if (!result) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
for (const session of chunk) {
|
for (const session of chunk) {
|
||||||
const value = result.success && result.counts ? result.counts[session.username] : undefined
|
const value = result?.success && result.counts ? result.counts[session.username] : undefined
|
||||||
updates[session.username] = typeof value === 'number' ? value : 0
|
chunkUpdates[session.username] = typeof value === 'number' ? value : 0
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载会话总消息数失败:', error)
|
console.error('加载会话总消息数失败:', error)
|
||||||
for (const session of chunk) {
|
for (const session of chunk) {
|
||||||
updates[session.username] = 0
|
chunkUpdates[session.username] = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(chunkUpdates).length > 0) {
|
||||||
|
setSessionMessageCounts(prev => ({ ...prev, ...chunkUpdates }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (const session of pending) {
|
for (const session of pending) {
|
||||||
loadingMessageCountsRef.current.delete(session.username)
|
loadingMessageCountsRef.current.delete(session.username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadTokenAtStart === sessionLoadTokenRef.current && Object.keys(updates).length > 0) {
|
|
||||||
setSessionMessageCounts(prev => ({ ...prev, ...updates }))
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => {
|
const ensureSessionMetrics = useCallback(async (targetSessions: SessionRow[]) => {
|
||||||
@@ -787,35 +887,43 @@ function ExportPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
|
const prioritySessions = [
|
||||||
|
...sessions.filter(session => session.kind === activeTab),
|
||||||
|
...sessions.filter(session => session.kind !== activeTab)
|
||||||
|
]
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (cursor >= sessions.length) {
|
if (cursor >= prioritySessions.length) {
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chunk = sessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH)
|
const chunk = prioritySessions.slice(cursor, cursor + MESSAGE_COUNT_BACKGROUND_BATCH)
|
||||||
cursor += MESSAGE_COUNT_BACKGROUND_BATCH
|
cursor += MESSAGE_COUNT_BACKGROUND_BATCH
|
||||||
void ensureSessionMessageCounts(chunk)
|
void ensureSessionMessageCounts(chunk)
|
||||||
}, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS)
|
}, MESSAGE_COUNT_BACKGROUND_INTERVAL_MS)
|
||||||
|
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [sessions, ensureSessionMessageCounts])
|
}, [sessions, activeTab, ensureSessionMessageCounts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessions.length === 0) return
|
if (sessions.length === 0) return
|
||||||
|
const prioritySessions = [
|
||||||
|
...sessions.filter(session => session.kind === activeTab),
|
||||||
|
...sessions.filter(session => session.kind !== activeTab)
|
||||||
|
]
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
if (cursor >= sessions.length) {
|
if (cursor >= prioritySessions.length) {
|
||||||
window.clearInterval(timer)
|
window.clearInterval(timer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chunk = sessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH)
|
const chunk = prioritySessions.slice(cursor, cursor + METRICS_BACKGROUND_BATCH)
|
||||||
cursor += METRICS_BACKGROUND_BATCH
|
cursor += METRICS_BACKGROUND_BATCH
|
||||||
void ensureSessionMetrics(chunk)
|
void ensureSessionMetrics(chunk)
|
||||||
}, METRICS_BACKGROUND_INTERVAL_MS)
|
}, METRICS_BACKGROUND_INTERVAL_MS)
|
||||||
|
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [sessions, ensureSessionMetrics])
|
}, [sessions, activeTab, ensureSessionMetrics])
|
||||||
|
|
||||||
const selectedCount = selectedSessions.size
|
const selectedCount = selectedSessions.size
|
||||||
|
|
||||||
@@ -1059,7 +1167,7 @@ function ExportPage() {
|
|||||||
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
|
const mergedExportedCount = Math.max(lastSnsExportPostCount, exportedPosts)
|
||||||
setLastSnsExportPostCount(mergedExportedCount)
|
setLastSnsExportPostCount(mergedExportedCount)
|
||||||
await configService.setExportLastSnsPostCount(mergedExportedCount)
|
await configService.setExportLastSnsPostCount(mergedExportedCount)
|
||||||
await loadSnsStats()
|
await loadSnsStats({ full: true })
|
||||||
|
|
||||||
updateTask(next.id, task => ({
|
updateTask(next.id, task => ({
|
||||||
...task,
|
...task,
|
||||||
@@ -1519,6 +1627,7 @@ function ExportPage() {
|
|||||||
const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0
|
const hasTabCountsSource = prefetchedTabCounts !== null || sessions.length > 0
|
||||||
const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource
|
const isTabCountComputing = isTabCountsLoading && !hasTabCountsSource
|
||||||
const isSessionCardStatsLoading = isLoading || isBaseConfigLoading
|
const isSessionCardStatsLoading = isLoading || isBaseConfigLoading
|
||||||
|
const isSnsCardStatsLoading = !hasSeededSnsStats
|
||||||
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
const taskRunningCount = tasks.filter(task => task.status === 'running').length
|
||||||
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
const taskQueuedCount = tasks.filter(task => task.status === 'queued').length
|
||||||
const showInitialSkeleton = isLoading && sessions.length === 0
|
const showInitialSkeleton = isLoading && sessions.length === 0
|
||||||
@@ -1574,7 +1683,7 @@ function ExportPage() {
|
|||||||
{contentCards.map(card => {
|
{contentCards.map(card => {
|
||||||
const Icon = card.icon
|
const Icon = card.icon
|
||||||
const isCardStatsLoading = card.type === 'sns'
|
const isCardStatsLoading = card.type === 'sns'
|
||||||
? (isSnsStatsLoading || isBaseConfigLoading)
|
? isSnsCardStatsLoading
|
||||||
: isSessionCardStatsLoading
|
: isSessionCardStatsLoading
|
||||||
return (
|
return (
|
||||||
<div key={card.type} className="content-card">
|
<div key={card.type} className="content-card">
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ export const CONFIG_KEYS = {
|
|||||||
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
EXPORT_LAST_SESSION_RUN_MAP: 'exportLastSessionRunMap',
|
||||||
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
EXPORT_LAST_CONTENT_RUN_MAP: 'exportLastContentRunMap',
|
||||||
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
EXPORT_LAST_SNS_POST_COUNT: 'exportLastSnsPostCount',
|
||||||
|
EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap',
|
||||||
|
EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap',
|
||||||
|
|
||||||
// 安全
|
// 安全
|
||||||
AUTH_ENABLED: 'authEnabled',
|
AUTH_ENABLED: 'authEnabled',
|
||||||
@@ -449,6 +451,104 @@ export async function setExportLastSnsPostCount(count: number): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
|
await config.set(CONFIG_KEYS.EXPORT_LAST_SNS_POST_COUNT, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExportSessionMessageCountCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
counts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportSnsStatsCacheItem {
|
||||||
|
updatedAt: number
|
||||||
|
totalPosts: number
|
||||||
|
totalFriends: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSessionMessageCountCache(scopeKey: string): Promise<ExportSessionMessageCountCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const rawUpdatedAt = (rawItem as Record<string, unknown>).updatedAt
|
||||||
|
const rawCounts = (rawItem as Record<string, unknown>).counts
|
||||||
|
if (!rawCounts || typeof rawCounts !== 'object') return null
|
||||||
|
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const [sessionId, countRaw] of Object.entries(rawCounts as Record<string, unknown>)) {
|
||||||
|
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||||
|
counts[sessionId] = Math.floor(countRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt: typeof rawUpdatedAt === 'number' && Number.isFinite(rawUpdatedAt) ? rawUpdatedAt : 0,
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSessionMessageCountCache(scopeKey: string, counts: Record<string, number>): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
const normalized: Record<string, number> = {}
|
||||||
|
for (const [sessionId, countRaw] of Object.entries(counts || {})) {
|
||||||
|
if (typeof countRaw === 'number' && Number.isFinite(countRaw) && countRaw >= 0) {
|
||||||
|
normalized[sessionId] = Math.floor(countRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
counts: normalized
|
||||||
|
}
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExportSnsStatsCache(scopeKey: string): Promise<ExportSnsStatsCacheItem | null> {
|
||||||
|
if (!scopeKey) return null
|
||||||
|
const value = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
|
||||||
|
if (!value || typeof value !== 'object') return null
|
||||||
|
const rawMap = value as Record<string, unknown>
|
||||||
|
const rawItem = rawMap[scopeKey]
|
||||||
|
if (!rawItem || typeof rawItem !== 'object') return null
|
||||||
|
|
||||||
|
const raw = rawItem as Record<string, unknown>
|
||||||
|
const totalPosts = typeof raw.totalPosts === 'number' && Number.isFinite(raw.totalPosts) && raw.totalPosts >= 0
|
||||||
|
? Math.floor(raw.totalPosts)
|
||||||
|
: 0
|
||||||
|
const totalFriends = typeof raw.totalFriends === 'number' && Number.isFinite(raw.totalFriends) && raw.totalFriends >= 0
|
||||||
|
? Math.floor(raw.totalFriends)
|
||||||
|
: 0
|
||||||
|
const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)
|
||||||
|
? raw.updatedAt
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return { updatedAt, totalPosts, totalFriends }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setExportSnsStatsCache(
|
||||||
|
scopeKey: string,
|
||||||
|
stats: { totalPosts: number; totalFriends: number }
|
||||||
|
): Promise<void> {
|
||||||
|
if (!scopeKey) return
|
||||||
|
const current = await config.get(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP)
|
||||||
|
const map = current && typeof current === 'object'
|
||||||
|
? { ...(current as Record<string, unknown>) }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
map[scopeKey] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
totalPosts: Number.isFinite(stats.totalPosts) ? Math.max(0, Math.floor(stats.totalPosts)) : 0,
|
||||||
|
totalFriends: Number.isFinite(stats.totalFriends) ? Math.max(0, Math.floor(stats.totalFriends)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map)
|
||||||
|
}
|
||||||
|
|
||||||
// === 安全相关 ===
|
// === 安全相关 ===
|
||||||
|
|
||||||
export async function getAuthEnabled(): Promise<boolean> {
|
export async function getAuthEnabled(): Promise<boolean> {
|
||||||
|
|||||||
1
src/types/electron.d.ts
vendored
1
src/types/electron.d.ts
vendored
@@ -554,6 +554,7 @@ export interface ElectronAPI {
|
|||||||
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
|
||||||
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
|
||||||
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
|
||||||
|
getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }>
|
||||||
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }>
|
getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number }; error?: string }>
|
||||||
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }>
|
||||||
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user