From 5945942acd7aa8497f65212949767e055eeef42c Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Thu, 5 Mar 2026 14:43:03 +0800 Subject: [PATCH 01/31] =?UTF-8?q?=E4=BF=AE=E5=A4=8Ddev=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=BF=98=E6=9C=89=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/electron/main.ts b/electron/main.ts index 4ec00b7..b91f2ad 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2372,6 +2372,13 @@ app.whenReady().then(async () => { }) }) +app.on('before-quit', async () => { + // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 + try { await httpService.stop() } catch {} + // 终止 wcdb Worker 线程,避免线程阻止进程退出 + try { wcdbService.shutdown() } catch {} +}) + app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() From 2a9f0f24fd9e4bbafdc6902d2665cf38a5678ecf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 15:39:59 +0800 Subject: [PATCH 02/31] perf(export): speed up session detail stats loading --- electron/services/chatService.ts | 49 ++++++++-------- src/pages/ExportPage.tsx | 99 +++++++++++--------------------- 2 files changed, 56 insertions(+), 92 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index e50817e..4c5e43e 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -5209,39 +5209,36 @@ class ChatService { return { success: true, detail: cachedDetail.detail } } - const [tableStatsResult, statsResult] = await Promise.allSettled([ - wcdbService.getMessageTableStats(normalizedSessionId), - (async (): Promise => { - const cachedStats = this.getSessionStatsCacheEntry(normalizedSessionId) - if (cachedStats && this.supportsRequestedRelation(cachedStats.entry, false)) { - return this.fromSessionStatsCacheStats(cachedStats.entry.stats) - } - const myWxid = this.configService.get('myWxid') || '' - const selfIdentitySet = new Set(this.buildIdentityKeys(myWxid)) - const stats = await this.getOrComputeSessionExportStats(normalizedSessionId, false, selfIdentitySet) - this.setSessionStatsCacheEntry(normalizedSessionId, stats, false) - return stats - })() - ]) - - const statsSnapshot = statsResult.status === 'fulfilled' - ? statsResult.value - : null - const firstMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.firstTimestamp) - ? Math.max(0, Math.floor(statsSnapshot.firstTimestamp as number)) - : undefined - const latestMessageTime = statsSnapshot && Number.isFinite(statsSnapshot.lastTimestamp) - ? Math.max(0, Math.floor(statsSnapshot.lastTimestamp as number)) - : undefined + const tableStatsResult = await wcdbService.getMessageTableStats(normalizedSessionId) const messageTables: { dbName: string; tableName: string; count: number }[] = [] - if (tableStatsResult.status === 'fulfilled' && tableStatsResult.value.success && tableStatsResult.value.tables) { - for (const row of tableStatsResult.value.tables) { + let firstMessageTime: number | undefined + let latestMessageTime: number | undefined + if (tableStatsResult.success && tableStatsResult.tables) { + for (const row of tableStatsResult.tables) { messageTables.push({ dbName: basename(row.db_path || ''), tableName: row.table_name || '', count: parseInt(row.count || '0', 10) }) + + const firstTs = this.getRowInt( + row, + ['first_timestamp', 'firstTimestamp', 'first_time', 'firstTime', 'min_create_time', 'minCreateTime'], + 0 + ) + if (firstTs > 0 && (firstMessageTime === undefined || firstTs < firstMessageTime)) { + firstMessageTime = firstTs + } + + const lastTs = this.getRowInt( + row, + ['last_timestamp', 'lastTimestamp', 'last_time', 'lastTime', 'max_create_time', 'maxCreateTime'], + 0 + ) + if (lastTs > 0 && (latestMessageTime === undefined || lastTs > latestMessageTime)) { + latestMessageTime = lastTs + } } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 3b56b13..01e219f 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3514,7 +3514,7 @@ function ExportPage() { window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + { includeRelations: false, allowStaleCache: true } ) ]) @@ -3535,59 +3535,56 @@ function ExportPage() { } } - let refreshIncludeRelations = false - let shouldRefreshStats = false if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - refreshIncludeRelations = Boolean(cacheMeta?.includeRelations) if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, refreshIncludeRelations) + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) } else if (cacheMeta) { setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { ...prev, - relationStatsLoaded: refreshIncludeRelations || prev.relationStatsLoaded, statsUpdatedAt: cacheMeta.updatedAt, statsStale: cacheMeta.stale } }) } - shouldRefreshStats = Array.isArray(statsResultSettled.value.needsRefresh) && - statsResultSettled.value.needsRefresh.includes(normalizedSessionId) } - if (shouldRefreshStats) { - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats( - normalizedSessionId, - metric, - cacheMeta, - refreshIncludeRelations ? true : undefined - ) - } - } - } catch (error) { - console.error('导出页刷新会话统计失败:', error) - } finally { - if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) } } - })() - } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) } finally { @@ -3619,36 +3616,6 @@ function ExportPage() { if (metric) { applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true) } - - const needRefresh = relationResult.success && - Array.isArray(relationResult.needsRefresh) && - relationResult.needsRefresh.includes(normalizedSessionId) - - if (needRefresh) { - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: true, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const freshMetric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const freshMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (freshMetric) { - applySessionDetailStats(normalizedSessionId, freshMetric, freshMeta, true) - } - } - } catch (error) { - console.error('导出页刷新会话关系统计失败:', error) - } finally { - if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) - } - } - })() - } } catch (error) { console.error('导出页加载会话关系统计失败:', error) } finally { From a5358b82f67221c6fc184b2c71e694c49b08b865 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 16:05:58 +0800 Subject: [PATCH 03/31] perf(export): further optimize detail loading and prioritize session stats --- electron/main.ts | 51 ++++++++-- electron/preload.ts | 12 ++- electron/services/chatService.ts | 48 ++++----- src/App.tsx | 6 +- src/pages/ChatPage.tsx | 21 ++-- src/pages/ExportPage.tsx | 162 +++++++++++++++++++------------ src/types/electron.d.ts | 14 ++- 7 files changed, 205 insertions(+), 109 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index b91f2ad..7770856 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -87,6 +87,7 @@ let onboardingWindow: BrowserWindow | null = null // Splash 启动窗口 let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() +const sessionChatWindowSources = new Map() const keyService = new KeyService() let mainWindowReady = false @@ -123,6 +124,32 @@ interface AnnualReportYearsTaskState { updatedAt: number } +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export' +) => { + const query = new URLSearchParams({ + sessionId, + source + }).toString() + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) + return + } + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-window?${query}` + }) +} + const annualReportYearsLoadTasks = new Map() const annualReportYearsTaskByCacheKey = new Map() const annualReportYearsSnapshotCache = new Map() @@ -688,12 +715,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { /** * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) */ -function createSessionChatWindow(sessionId: string) { +function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return null + const normalizedSource = normalizeSessionChatWindowSource(options?.source) const existing = sessionChatWindows.get(normalizedSessionId) if (existing && !existing.isDestroyed()) { + const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' + if (trackedSource !== normalizedSource) { + loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } if (existing.isMinimized()) { existing.restore() } @@ -730,10 +763,9 @@ function createSessionChatWindow(sessionId: string) { autoHideMenuBar: true }) - const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`) + loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource) + if (process.env.VITE_DEV_SERVER_URL) { win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (win.webContents.isDevToolsOpened()) { @@ -744,10 +776,6 @@ function createSessionChatWindow(sessionId: string) { event.preventDefault() } }) - } else { - win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-window?${sessionParam}` - }) } win.once('ready-to-show', () => { @@ -759,10 +787,12 @@ function createSessionChatWindow(sessionId: string) { const tracked = sessionChatWindows.get(normalizedSessionId) if (tracked === win) { sessionChatWindows.delete(normalizedSessionId) + sessionChatWindowSources.delete(normalizedSessionId) } }) sessionChatWindows.set(normalizedSessionId, win) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) return win } @@ -1071,8 +1101,8 @@ function registerIpcHandlers() { }) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) - ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { - const win = createSessionChatWindow(sessionId) + ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { + const win = createSessionChatWindow(sessionId, options) return Boolean(win) }) @@ -1410,6 +1440,7 @@ function registerIpcHandlers() { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) diff --git a/electron/preload.ts b/electron/preload.ts index c173d10..3c51ee3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -99,8 +99,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), - openSessionChatWindow: (sessionId: string) => - ipcRenderer.invoke('window:openSessionChatWindow', sessionId) + openSessionChatWindow: (sessionId: string, options?: { source?: 'chat' | 'export' }) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -174,7 +174,13 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), getGroupMyMessageCountHint: (chatroomId: string) => ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 4c5e43e..68cb598 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -164,6 +164,7 @@ interface ExportSessionStatsOptions { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean } interface ExportSessionStatsCacheMeta { @@ -5354,6 +5355,7 @@ class ChatService { const forceRefresh = options.forceRefresh === true const allowStaleCache = options.allowStaleCache === true const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true + const cacheOnly = options.cacheOnly === true const normalizedSessionIds = Array.from( new Set( @@ -5377,32 +5379,34 @@ class ChatService { ? this.getGroupMyMessageCountHintEntry(sessionId) : null const cachedResult = this.getSessionStatsCacheEntry(sessionId) - if (!forceRefresh && !preferAccurateSpecialTypes) { - if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { - const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs - if (!stale || allowStaleCache) { - resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) - if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { - resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount - } - cacheMeta[sessionId] = { - updatedAt: cachedResult.entry.updatedAt, - stale, - includeRelations: cachedResult.entry.includeRelations, - source: cachedResult.source - } - if (stale) { - needsRefreshSet.add(sessionId) - } - continue + const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes) + if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache || cacheOnly) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) } - } - // allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。 - if (allowStaleCache && cachedResult) { - needsRefreshSet.add(sessionId) continue } } + // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。 + if (canUseCache && allowStaleCache && cachedResult) { + needsRefreshSet.add(sessionId) + continue + } + if (cacheOnly) { + continue + } pendingSessionIds.push(sessionId) } diff --git a/src/App.tsx b/src/App.tsx index 9d040d2..05d1990 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -402,8 +402,10 @@ function App() { // 独立会话聊天窗口(仅显示聊天内容区域) if (isStandaloneChatWindow) { - const sessionId = new URLSearchParams(location.search).get('sessionId') || '' - return + const params = new URLSearchParams(location.search) + const sessionId = params.get('sessionId') || '' + const standaloneSource = params.get('source') + return } // 独立通知窗口 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 90df9aa..56d7ba5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -204,6 +204,7 @@ function formatYmdHmDateTime(timestamp?: number): string { interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null + standaloneSource?: string | null } @@ -408,8 +409,10 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(props: ChatPageProps) { - const { standaloneSessionWindow = false, initialSessionId = null } = props + const { standaloneSessionWindow = false, initialSessionId = null, standaloneSource = null } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) + const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) + const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { @@ -3863,13 +3866,15 @@ function ChatPage(props: ChatPageProps) { > - + {!shouldHideStandaloneDetailButton && ( + + )} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 01e219f..0615f8b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -163,6 +163,7 @@ interface TimeRangeDialogDraft { } const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -1307,6 +1308,8 @@ function ExportPage() { const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) const activeTabRef = useRef('private') + const detailStatsPriorityRef = useRef(false) + const sessionPreciseRefreshAtRef = useRef>({}) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1894,6 +1897,9 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (prioritizedSessionIds.length > 0) { const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1902,6 +1908,9 @@ function ExportPage() { } } + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (remainingSessionIds.length > 0) { const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1930,6 +1939,7 @@ function ExportPage() { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken sessionsHydratedAtRef.current = 0 + sessionPreciseRefreshAtRef.current = {} setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 @@ -2027,12 +2037,14 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { + if (detailStatsPriorityRef.current) return let contactMap = { ...cachedContactMap } let avatarEntries = { ...cachedAvatarEntries } let hasFreshNetworkData = false let hasNetworkContactsSnapshot = false if (isStale()) return + if (detailStatsPriorityRef.current) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return @@ -2091,6 +2103,7 @@ function ExportPage() { if (needsEnrichment.length > 0) { for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { if (isStale()) return + if (detailStatsPriorityRef.current) return const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) if (batch.length === 0) continue try { @@ -3399,6 +3412,11 @@ function ExportPage() { const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return + const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` + + detailStatsPriorityRef.current = true + sessionCountRequestIdRef.current += 1 + setIsLoadingSessionCounts(false) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) @@ -3510,19 +3528,13 @@ function ExportPage() { } try { - const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ - window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, allowStaleCache: true } - ) - ]) - - if (requestSeq !== detailRequestSeqRef.current) return - - if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { - const detail = extraResultSettled.value.detail - if (detail) { + const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + void (async () => { + try { + const extraResult = await extraPromise + if (requestSeq !== detailRequestSeqRef.current) return + if (!extraResult.success || !extraResult.detail) return + const detail = extraResult.detail setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { @@ -3532,62 +3544,86 @@ function ExportPage() { messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] } }) - } - } - - if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { - const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } - - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } } catch (error) { - console.error('导出页刷新会话统计失败:', error) + console.error('导出页加载会话详情补充信息失败:', error) } finally { if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionDetailExtra(false) } } })() + + let quickMetric: SessionExportMetric | undefined + let quickCacheMeta: SessionExportCacheMeta | undefined + try { + const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (quickStatsResult.success) { + quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined + quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (quickMetric) { + applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false) + } else if (quickCacheMeta) { + const cacheMeta = quickCacheMeta + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页读取会话统计缓存失败:', error) + } + + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 + const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS + const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + + if (shouldRunPreciseRefresh) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now() + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() + } } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) - } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingSessionDetailExtra(false) } @@ -3627,6 +3663,7 @@ function ExportPage() { const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 + detailStatsPriorityRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) @@ -3636,6 +3673,7 @@ function ExportPage() { const openSessionDetail = useCallback((sessionId: string) => { if (!sessionId) return + detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) void loadSessionDetail(sessionId) }, [loadSessionDetail]) @@ -3827,7 +3865,7 @@ function ExportPage() { title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'} onClick={() => { if (!canExport) return - void window.electronAPI.window.openSessionChatWindow(contact.username) + void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' }) }} > diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a3abc38..079d30f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,5 +1,9 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' +export interface SessionChatWindowOpenOptions { + source?: 'chat' | 'export' +} + export interface ElectronAPI { window: { minimize: () => void @@ -13,7 +17,7 @@ export interface ElectronAPI { resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise - openSessionChatWindow: (sessionId: string) => Promise + openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise } config: { get: (key: string) => Promise @@ -250,7 +254,13 @@ export interface ElectronAPI { }> getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => Promise<{ success: boolean data?: Record Date: Thu, 5 Mar 2026 16:18:00 +0800 Subject: [PATCH 04/31] feat(export): move open-chat action below message count --- src/pages/ExportPage.scss | 57 ++++++++++++++++++++------------------- src/pages/ExportPage.tsx | 23 +++++++++------- 2 files changed, 44 insertions(+), 36 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 270f0c0..481ae45 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1347,8 +1347,10 @@ width: var(--contacts-message-col-width); min-width: var(--contacts-message-col-width); display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 4px; flex-shrink: 0; text-align: center; } @@ -1397,6 +1399,30 @@ .row-message-stat.total .row-message-count-value { font-size: 13px; } + + .row-open-chat-link { + border: none; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); + font-size: 12px; + line-height: 1.2; + font-weight: 600; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + } } .table-virtuoso { @@ -1546,33 +1572,6 @@ gap: 6px; } - .row-open-chat-btn { - border: 1px solid color-mix(in srgb, var(--primary) 38%, var(--border-color)); - border-radius: 8px; - padding: 7px 10px; - background: color-mix(in srgb, var(--primary) 12%, var(--bg-secondary)); - color: var(--primary); - font-size: 12px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 5px; - white-space: nowrap; - - &:hover:not(:disabled) { - background: color-mix(in srgb, var(--primary) 18%, var(--bg-secondary)); - border-color: color-mix(in srgb, var(--primary) 55%, var(--border-color)); - } - - &:disabled { - opacity: 0.65; - cursor: not-allowed; - color: var(--text-tertiary); - border-color: var(--border-color); - background: var(--bg-secondary); - } - } - .row-detail-btn { border: 1px solid var(--border-color); border-radius: 8px; @@ -2704,6 +2703,10 @@ font-size: 12px; } + .table-wrap .row-open-chat-link { + font-size: 11px; + } + .export-dialog-overlay { padding: 10px; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0615f8b..a9f4ee8 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3825,6 +3825,11 @@ function ExportPage() { : typeof displayedMessageCount === 'number' ? displayedMessageCount.toLocaleString('zh-CN') : '获取中' + const openChatLabel = contact.type === 'friend' + ? '打开私聊' + : contact.type === 'group' + ? '打开群聊' + : '打开对话' return (
@@ -3856,21 +3861,21 @@ function ExportPage() { {messageCountLabel}
-
-
-
+ {canExport && ( + )} +
+
+
)}
+
+ {emojiLabel} +
+
+ {voiceLabel} +
+
+ {imageLabel} +
+
+ {videoLabel} +
)} - {isGroupChatSession(currentSession.username) && ( + {isCurrentSessionGroup && (
- {decodeHtmlEntities(post.nickname)} + {formatTime(post.createTime)}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index bc52b0c..013eaae 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -179,6 +179,30 @@ flex-shrink: 0; } +.author-trigger-btn { + background: transparent; + border: none; + padding: 0; + margin: 0; + color: inherit; + cursor: pointer; +} + +.avatar-trigger { + border-radius: 12px; + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } +} + .post-content-col { flex: 1; min-width: 0; @@ -206,6 +230,30 @@ margin-bottom: 2px; } + .author-name-trigger { + align-self: flex-start; + border-radius: 6px; + margin-bottom: 2px; + + .author-name { + transition: color 0.15s ease, text-decoration-color 0.15s ease; + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 2px; + margin-bottom: 0; + } + + &:hover .author-name { + color: var(--primary); + text-decoration-color: currentColor; + } + + &:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; + } + } + .post-time { font-size: 12px; color: var(--text-tertiary); @@ -1317,6 +1365,116 @@ } } +.author-timeline-dialog { + background: var(--sns-card-bg); + border-radius: var(--sns-border-radius-lg); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15); + width: min(860px, 94vw); + max-height: 86vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + overflow: hidden; + animation: slide-up-fade 0.3s cubic-bezier(0.16, 1, 0.3, 1); +} + +.author-timeline-header { + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + display: flex; + align-items: flex-start; + justify-content: space-between; + + .close-btn { + background: none; + border: none; + color: var(--text-tertiary); + cursor: pointer; + padding: 6px; + border-radius: 6px; + display: flex; + + &:hover { + background: var(--bg-primary); + color: var(--text-primary); + } + } +} + +.author-timeline-meta { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.author-timeline-meta-text { + min-width: 0; + + h3 { + margin: 0; + font-size: 16px; + color: var(--text-primary); + } +} + +.author-timeline-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); +} + +.author-timeline-body { + padding: 16px; + overflow-y: auto; + min-height: 180px; + max-height: calc(86vh - 96px); +} + +.author-timeline-posts-list { + gap: 16px; +} + +.author-timeline-loading { + margin-top: 12px; +} + +.author-timeline-empty { + padding: 42px 10px 30px; + text-align: center; + font-size: 14px; + color: var(--text-secondary); +} + +.author-timeline-load-more { + display: block; + margin: 12px auto 2px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + border-radius: 999px; + padding: 7px 16px; + font-size: 13px; + cursor: pointer; + + &:hover:not(:disabled) { + color: var(--primary); + border-color: var(--primary); + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + @keyframes slide-up-fade { from { opacity: 0; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e7caad5..53d34ad 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -5,6 +5,7 @@ import './SnsPage.scss' import { SnsPost } from '../types/sns' import { SnsPostItem } from '../components/Sns/SnsPostItem' import { SnsFilterPanel } from '../components/Sns/SnsFilterPanel' +import { Avatar } from '../components/Avatar' import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 @@ -28,6 +29,12 @@ interface SnsOverviewStats { type OverviewStatsStatus = 'loading' | 'ready' | 'error' +interface AuthorTimelineTarget { + username: string + nickname: string + avatarUrl?: string +} + export default function SnsPage() { const [posts, setPosts] = useState([]) const [loading, setLoading] = useState(false) @@ -55,6 +62,11 @@ export default function SnsPage() { // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) const [debugPost, setDebugPost] = useState(null) + const [authorTimelineTarget, setAuthorTimelineTarget] = useState(null) + const [authorTimelinePosts, setAuthorTimelinePosts] = useState([]) + const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) + const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) + const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -89,6 +101,9 @@ export default function SnsPage() { const cacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) + const authorTimelinePostsRef = useRef([]) + const authorTimelineLoadingRef = useRef(false) + const authorTimelineRequestTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -109,6 +124,9 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) + useEffect(() => { + authorTimelinePostsRef.current = authorTimelinePosts + }, [authorTimelinePosts]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -132,6 +150,18 @@ export default function SnsPage() { return `${year}-${month}-${day}` } + const decodeHtmlEntities = (text: string): string => { + if (!text) return '' + return text + .replace(//g, '$1') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .trim() + } + const isDefaultViewNow = useCallback(() => { return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current }, []) @@ -445,6 +475,117 @@ export default function SnsPage() { } }, []) + const closeAuthorTimeline = useCallback(() => { + authorTimelineRequestTokenRef.current += 1 + authorTimelineLoadingRef.current = false + setAuthorTimelineTarget(null) + setAuthorTimelinePosts([]) + setAuthorTimelineLoading(false) + setAuthorTimelineLoadingMore(false) + setAuthorTimelineHasMore(false) + }, []) + + const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { + const { reset = false } = options + if (authorTimelineLoadingRef.current) return + + authorTimelineLoadingRef.current = true + if (reset) { + setAuthorTimelineLoading(true) + setAuthorTimelineLoadingMore(false) + setAuthorTimelineHasMore(false) + } else { + setAuthorTimelineLoadingMore(true) + } + + const requestToken = ++authorTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTs: number | undefined = undefined + + if (!reset && authorTimelinePostsRef.current.length > 0) { + endTs = authorTimelinePostsRef.current[authorTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline( + limit, + 0, + [target.username], + '', + undefined, + endTs + ) + + if (requestToken !== authorTimelineRequestTokenRef.current) return + if (!result.success || !result.timeline) { + if (reset) { + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + } + return + } + + if (reset) { + const sorted = [...result.timeline].sort((a, b) => b.createTime - a.createTime) + setAuthorTimelinePosts(sorted) + setAuthorTimelineHasMore(result.timeline.length >= limit) + return + } + + const existingIds = new Set(authorTimelinePostsRef.current.map((p) => p.id)) + const uniqueOlder = result.timeline.filter((p) => !existingIds.has(p.id)) + if (uniqueOlder.length > 0) { + const merged = [...authorTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setAuthorTimelinePosts(merged) + } + if (result.timeline.length < limit) { + setAuthorTimelineHasMore(false) + } + } catch (error) { + console.error('Failed to load author timeline:', error) + if (requestToken === authorTimelineRequestTokenRef.current && reset) { + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + } + } finally { + if (requestToken === authorTimelineRequestTokenRef.current) { + authorTimelineLoadingRef.current = false + setAuthorTimelineLoading(false) + setAuthorTimelineLoadingMore(false) + } + } + }, []) + + const openAuthorTimeline = useCallback((post: SnsPost) => { + authorTimelineRequestTokenRef.current += 1 + authorTimelineLoadingRef.current = false + const target = { + username: post.username, + nickname: post.nickname, + avatarUrl: post.avatarUrl + } + setAuthorTimelineTarget(target) + setAuthorTimelinePosts([]) + setAuthorTimelineHasMore(false) + void loadAuthorTimelinePosts(target, { reset: true }) + }, [loadAuthorTimelinePosts]) + + const loadMoreAuthorTimeline = useCallback(() => { + if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return + void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) + }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) + + const handlePostDelete = useCallback((postId: string) => { + setPosts(prev => { + const next = prev.filter(p => p.id !== postId) + void persistSnsPageCache({ posts: next }) + return next + }) + setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) + void loadOverviewStats() + }, [loadOverviewStats, persistSnsPageCache]) + // Initial Load & Listeners useEffect(() => { void hydrateSnsPageCache() @@ -474,6 +615,17 @@ export default function SnsPage() { return () => clearTimeout(timer) }, [selectedUsernames, searchKeyword, jumpTargetDate, loadPosts]) + useEffect(() => { + if (!authorTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeAuthorTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [authorTimelineTarget, closeAuthorTimeline]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -492,6 +644,22 @@ export default function SnsPage() { } } + const handleAuthorTimelineScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget + if (scrollHeight - scrollTop - clientHeight < 260) { + loadMoreAuthorTimeline() + } + } + + const renderAuthorTimelineStats = () => { + if (authorTimelineLoading) return '加载中...' + if (authorTimelinePosts.length === 0) return '暂无朋友圈' + const latest = authorTimelinePosts[0]?.createTime ?? null + const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null + const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length} 条` : `共 ${authorTimelinePosts.length} 条` + return `${loadedLabel} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + } + return (
@@ -578,14 +746,8 @@ export default function SnsPage() { } }} onDebug={(p) => setDebugPost(p)} - onDelete={(postId) => { - setPosts(prev => { - const next = prev.filter(p => p.id !== postId) - void persistSnsPageCache({ posts: next }) - return next - }) - loadOverviewStats() - }} + onDelete={handlePostDelete} + onOpenAuthorPosts={openAuthorTimeline} /> ))}
@@ -657,6 +819,76 @@ export default function SnsPage() { currentDate={jumpTargetDate || new Date()} /> + {authorTimelineTarget && ( +
+
e.stopPropagation()}> +
+
+ +
+

{decodeHtmlEntities(authorTimelineTarget.nickname)}

+
@{authorTimelineTarget.username}
+
{renderAuthorTimelineStats()}
+
+
+ +
+ +
+ {authorTimelinePosts.length > 0 && ( +
+ {authorTimelinePosts.map(post => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={(p) => setDebugPost(p)} + onDelete={handlePostDelete} + onOpenAuthorPosts={openAuthorTimeline} + /> + ))} +
+ )} + + {authorTimelineLoading && ( +
+ + 正在加载该用户朋友圈... +
+ )} + + {!authorTimelineLoading && authorTimelinePosts.length === 0 && ( +
该用户暂无朋友圈
+ )} + + {!authorTimelineLoading && authorTimelineHasMore && ( + + )} +
+
+
+ )} + {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}> From e6942bc2015f236bd593d654b7ea16df7c9b3682 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:11:04 +0800 Subject: [PATCH 09/31] feat(export): add session load detail modal with typed progress --- src/pages/ExportPage.scss | 145 +++++++++++++++ src/pages/ExportPage.tsx | 382 +++++++++++++++++++++++++++++++++++++- 2 files changed, 523 insertions(+), 4 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 8192e23..7c227ea 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -27,6 +27,27 @@ gap: 6px; } +.session-load-detail-entry { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 5px 10px; + font-size: 12px; + color: var(--text-secondary); + background: var(--bg-secondary); + cursor: pointer; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; + + &:hover { + border-color: color-mix(in srgb, var(--primary) 45%, var(--border-color)); + color: var(--text-primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-secondary)); + } +} + .export-section-title { margin: 0; font-size: 15px; @@ -87,6 +108,126 @@ } } +.session-load-detail-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.42); + display: flex; + align-items: center; + justify-content: center; + z-index: 2200; + padding: 20px; +} + +.session-load-detail-modal { + width: min(760px, 100%); + max-height: min(78vh, 860px); + overflow: hidden; + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28); + display: flex; + flex-direction: column; +} + +.session-load-detail-header { + padding: 14px 16px 10px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + } + + p { + margin: 4px 0 0; + font-size: 12px; + color: var(--text-tertiary); + } +} + +.session-load-detail-close { + border: 1px solid var(--border-color); + border-radius: 8px; + width: 28px; + height: 28px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--text-primary); + border-color: var(--text-tertiary); + } +} + +.session-load-detail-body { + padding: 12px 16px 16px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 14px; +} + +.session-load-detail-block { + border: 1px solid var(--border-color); + border-radius: 10px; + background: var(--card-bg); + + h5 { + margin: 0; + padding: 10px 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent); + font-size: 13px; + color: var(--text-primary); + } +} + +.session-load-detail-table { + display: flex; + flex-direction: column; + overflow-x: auto; +} + +.session-load-detail-row { + display: grid; + grid-template-columns: 1.1fr 1fr 0.8fr 0.8fr; + gap: 10px; + align-items: center; + padding: 9px 12px; + font-size: 12px; + color: var(--text-secondary); + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); + min-width: 540px; + + &:last-child { + border-bottom: none; + } + + > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &.header { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 600; + background: color-mix(in srgb, var(--bg-secondary) 75%, transparent); + } +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); @@ -2696,6 +2837,10 @@ font-size: 14px; } + .session-load-detail-entry { + margin-left: 0; + } + .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5bc9d8a..34c1960 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -177,6 +177,13 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const conversationTabLabels: Record = { + private: '私聊', + group: '群聊', + official: '公众号', + former_friend: '曾经的好友' +} + const getContentTypeLabel = (type: ContentType): string => { return contentTypeLabels[type] || type } @@ -690,6 +697,20 @@ interface SessionExportCacheMeta { source: 'memory' | 'disk' | 'fresh' } +type SessionLoadStageStatus = 'pending' | 'loading' | 'done' | 'failed' + +interface SessionLoadStageState { + status: SessionLoadStageStatus + startedAt?: number + finishedAt?: number + error?: string +} + +interface SessionLoadTraceState { + messageCount: SessionLoadStageState + mediaMetrics: SessionLoadStageState +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -909,6 +930,13 @@ const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefin ) } +const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' }) + +const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ + messageCount: createDefaultSessionLoadStage(), + mediaMetrics: createDefaultSessionLoadStage() +}) + const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange, @@ -1250,12 +1278,14 @@ function ExportPage() { const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) + const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadSession, setContactsLoadSession] = useState(null) const [contactsLoadIssue, setContactsLoadIssue] = useState(null) const [showContactsDiagnostics, setShowContactsDiagnostics] = useState(false) const [contactsDiagnosticTick, setContactsDiagnosticTick] = useState(Date.now()) const [showSessionDetailPanel, setShowSessionDetailPanel] = useState(false) + const [showSessionLoadDetailModal, setShowSessionLoadDetailModal] = useState(false) const [sessionDetail, setSessionDetail] = useState(null) const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false) const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false) @@ -1422,6 +1452,96 @@ function ExportPage() { sessionContentMetricsRef.current = sessionContentMetrics }, [sessionContentMetrics]) + const patchSessionLoadTraceStage = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState, + status: SessionLoadStageStatus, + options?: { force?: boolean; error?: string } + ) => { + if (sessionIds.length === 0) return + const now = Date.now() + setSessionLoadTraceMap(prev => { + let changed = false + const next = { ...prev } + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const prevTrace = next[sessionId] || createDefaultSessionLoadTrace() + const prevStage = prevTrace[stageKey] || createDefaultSessionLoadStage() + if (!options?.force && prevStage.status === 'done' && status !== 'done') { + continue + } + let stageChanged = false + const nextStage: SessionLoadStageState = { ...prevStage } + if (nextStage.status !== status) { + nextStage.status = status + stageChanged = true + } + if (status === 'loading') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (nextStage.finishedAt) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'done') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + if (nextStage.error) { + nextStage.error = undefined + stageChanged = true + } + } else if (status === 'failed') { + if (!nextStage.startedAt) { + nextStage.startedAt = now + stageChanged = true + } + if (!nextStage.finishedAt) { + nextStage.finishedAt = now + stageChanged = true + } + const nextError = options?.error || '加载失败' + if (nextStage.error !== nextError) { + nextStage.error = nextError + stageChanged = true + } + } else if (status === 'pending') { + if (nextStage.startedAt !== undefined) { + nextStage.startedAt = undefined + stageChanged = true + } + if (nextStage.finishedAt !== undefined) { + nextStage.finishedAt = undefined + stageChanged = true + } + if (nextStage.error !== undefined) { + nextStage.error = undefined + stageChanged = true + } + } + if (!stageChanged) continue + next[sessionId] = { + ...prevTrace, + [stageKey]: nextStage + } + changed = true + } + return changed ? next : prev + }) + }, []) + const loadContactsList = useCallback(async (options?: { scopeKey?: string }) => { const scopeKey = options?.scopeKey || await ensureExportCacheScope() const loadVersion = contactsLoadVersionRef.current + 1 @@ -1953,12 +2073,13 @@ function ExportPage() { incoming.push(sessionId) } if (incoming.length === 0) return + patchSessionLoadTraceStage(incoming, 'mediaMetrics', 'pending') if (front) { sessionMediaMetricQueueRef.current = [...incoming, ...sessionMediaMetricQueueRef.current] } else { sessionMediaMetricQueueRef.current.push(...incoming) } - }, [isSessionMediaMetricReady]) + }, [isSessionMediaMetricReady, patchSessionLoadTraceStage]) const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { if (!data) return @@ -2011,6 +2132,7 @@ function ExportPage() { if (batchSessionIds.length === 0) { continue } + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') try { const cacheResult = await window.electronAPI.chat.getExportSessionStats( @@ -2035,13 +2157,21 @@ function ExportPage() { } } catch (error) { console.error('导出页加载会话媒体统计失败:', error) + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'failed', { + error: String(error) + }) } finally { + const completedSessionIds: string[] = [] for (const sessionId of batchSessionIds) { sessionMediaMetricLoadingSetRef.current.delete(sessionId) if (isSessionMediaMetricReady(sessionId)) { sessionMediaMetricReadySetRef.current.add(sessionId) + completedSessionIds.push(sessionId) } } + if (completedSessionIds.length > 0) { + patchSessionLoadTraceStage(completedSessionIds, 'mediaMetrics', 'done') + } } await new Promise(resolve => window.setTimeout(resolve, 0)) @@ -2052,7 +2182,7 @@ function ExportPage() { void runSessionMediaMetricWorker(runId) } } - }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady]) + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) const scheduleSessionMediaMetricWorker = useCallback(() => { if (!isSessionCountStageReady) return @@ -2076,6 +2206,9 @@ function ExportPage() { setIsSessionCountStageReady(false) const exportableSessions = sourceSessions.filter(session => session.hasSession) + const exportableSessionIds = exportableSessions.map(session => session.username) + const exportableSessionIdSet = new Set(exportableSessionIds) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'pending', { force: true }) const seededHintCounts = exportableSessions.reduce>((acc, session) => { const nextCount = normalizeMessageCount(session.messageCountHint) if (typeof nextCount === 'number') { @@ -2084,12 +2217,17 @@ function ExportPage() { return acc }, {}) const seededPersistentCounts = Object.entries(options?.seededCounts || {}).reduce>((acc, [sessionId, countRaw]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc const nextCount = normalizeMessageCount(countRaw) if (typeof nextCount === 'number') { acc[sessionId] = nextCount } return acc }, {}) + const seededPersistentSessionIds = Object.keys(seededPersistentCounts) + if (seededPersistentSessionIds.length > 0) { + patchSessionLoadTraceStage(seededPersistentSessionIds, 'messageCount', 'done') + } const seededCounts = { ...seededHintCounts, ...seededPersistentCounts } const accumulatedCounts: Record = { ...seededCounts } setSessionMessageCounts(seededCounts) @@ -2146,10 +2284,19 @@ function ExportPage() { return { ...accumulatedCounts } } if (prioritizedSessionIds.length > 0) { + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'loading') const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) if (isStale()) return { ...accumulatedCounts } if (priorityResult.success) { applyCounts(priorityResult.counts) + patchSessionLoadTraceStage(prioritizedSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + prioritizedSessionIds, + 'messageCount', + 'failed', + { error: priorityResult.error || '总消息数加载失败' } + ) } } @@ -2157,14 +2304,26 @@ function ExportPage() { return { ...accumulatedCounts } } if (remainingSessionIds.length > 0) { + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'loading') const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) if (isStale()) return { ...accumulatedCounts } if (remainingResult.success) { applyCounts(remainingResult.counts) + patchSessionLoadTraceStage(remainingSessionIds, 'messageCount', 'done') + } else { + patchSessionLoadTraceStage( + remainingSessionIds, + 'messageCount', + 'failed', + { error: remainingResult.error || '总消息数加载失败' } + ) } } } catch (error) { console.error('导出页加载会话消息总数失败:', error) + patchSessionLoadTraceStage(exportableSessionIds, 'messageCount', 'failed', { + error: String(error) + }) } finally { if (!isStale()) { setIsLoadingSessionCounts(false) @@ -2179,7 +2338,7 @@ function ExportPage() { } } return { ...accumulatedCounts } - }, [mergeSessionContentMetrics]) + }, [mergeSessionContentMetrics, patchSessionLoadTraceStage]) const loadSessions = useCallback(async () => { const loadToken = Date.now() @@ -2192,6 +2351,7 @@ function ExportPage() { sessionCountRequestIdRef.current += 1 setSessionMessageCounts({}) setSessionContentMetrics({}) + setSessionLoadTraceMap({}) setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2270,6 +2430,10 @@ function ExportPage() { } return acc }, {}) + const cachedContentMetricSessionIds = Object.keys(cachedContentMetrics) + if (cachedContentMetricSessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricSessionIds, 'mediaMetrics', 'done') + } if (isStale()) return if (Object.keys(cachedMessageCounts).length > 0) { @@ -2481,7 +2645,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, resetSessionMediaMetricLoader, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -3612,6 +3776,105 @@ function ExportPage() { return indexedContacts.map(item => item.contact) }, [contactsList, activeTab, searchKeyword, sessionMessageCounts, sessionRowByUsername]) + const keywordMatchedContactUsernameSet = useMemo(() => { + const keyword = searchKeyword.trim().toLowerCase() + const matched = new Set() + for (const contact of contactsList) { + if (!contact?.username) continue + if (!keyword) { + matched.add(contact.username) + continue + } + if ( + (contact.displayName || '').toLowerCase().includes(keyword) || + (contact.remark || '').toLowerCase().includes(keyword) || + (contact.nickname || '').toLowerCase().includes(keyword) || + (contact.alias || '').toLowerCase().includes(keyword) || + contact.username.toLowerCase().includes(keyword) + ) { + matched.add(contact.username) + } + } + return matched + }, [contactsList, searchKeyword]) + + const loadDetailTargetsByTab = useMemo(() => { + const targets: Record = { + private: [], + group: [], + official: [], + former_friend: [] + } + for (const session of sessions) { + if (!session.hasSession) continue + if (!keywordMatchedContactUsernameSet.has(session.username)) continue + targets[session.kind].push(session.username) + } + return targets + }, [keywordMatchedContactUsernameSet, sessions]) + + const formatLoadDetailTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { hour12: false }) + }, []) + + const getLoadDetailStatusLabel = useCallback((loaded: number, total: number, hasStarted: boolean): string => { + if (total <= 0) return '待加载' + if (loaded >= total) return `已完成 ${total}` + if (hasStarted) return `加载中 ${loaded}/${total}` + return '待加载' + }, []) + + const summarizeLoadTraceForTab = useCallback(( + sessionIds: string[], + stageKey: keyof SessionLoadTraceState + ) => { + const total = sessionIds.length + let loaded = 0 + let hasStarted = false + let earliestStart: number | undefined + let latestFinish: number | undefined + for (const sessionId of sessionIds) { + const stage = sessionLoadTraceMap[sessionId]?.[stageKey] + if (stage?.status === 'done') { + loaded += 1 + } + if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { + hasStarted = true + } + if (typeof stage?.startedAt === 'number') { + earliestStart = earliestStart === undefined + ? stage.startedAt + : Math.min(earliestStart, stage.startedAt) + } + if (typeof stage?.finishedAt === 'number') { + latestFinish = latestFinish === undefined + ? stage.finishedAt + : Math.max(latestFinish, stage.finishedAt) + } + } + return { + total, + loaded, + statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), + startedAt: earliestStart, + finishedAt: latestFinish + } + }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + + const sessionLoadDetailRows = useMemo(() => { + const tabOrder: ConversationTab[] = ['private', 'group', 'official', 'former_friend'] + return tabOrder.map((tab) => { + const sessionIds = loadDetailTargetsByTab[tab] || [] + return { + tab, + label: conversationTabLabels[tab], + messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics') + } + }) + }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) @@ -4053,6 +4316,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [closeSessionDetailPanel, showSessionDetailPanel]) + useEffect(() => { + if (!showSessionLoadDetailModal) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionLoadDetailModal(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [showSessionLoadDetailModal]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -4167,6 +4441,21 @@ function ExportPage() { const taskRunningCount = tasks.filter(task => task.status === 'running').length const taskQueuedCount = tasks.filter(task => task.status === 'queued').length const hasFilteredContacts = filteredContacts.length > 0 + const sessionLoadDetailUpdatedAt = useMemo(() => { + let latest = 0 + for (const row of sessionLoadDetailRows) { + const candidateTimes = [ + row.messageCount.finishedAt || row.messageCount.startedAt || 0, + row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0 + ] + for (const candidate of candidateTimes) { + if (candidate > latest) { + latest = candidate + } + } + } + return latest + }, [sessionLoadDetailRows]) const closeTaskCenter = useCallback(() => { setIsTaskCenterOpen(false) setExpandedPerfTaskId(null) @@ -4517,6 +4806,14 @@ function ExportPage() { '你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。' ]} /> +
@@ -4672,6 +4969,83 @@ function ExportPage() { )}
+ {showSessionLoadDetailModal && ( +
setShowSessionLoadDetailModal(false)} + > +
event.stopPropagation()} + > +
+
+

数据加载详情

+

+ 更新时间: + {sessionLoadDetailUpdatedAt > 0 + ? new Date(sessionLoadDetailUpdatedAt).toLocaleString('zh-CN') + : '暂无'} +

+
+ +
+ +
+
+
总消息数
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => ( +
+ {row.label} + {row.messageCount.statusLabel} + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ))} +
+
+ +
+
多媒体统计(表情包/图片/视频/语音)
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => ( +
+ {row.label} + {row.mediaMetrics.statusLabel} + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ))} +
+
+
+
+
+ )} + {showSessionDetailPanel && (
Date: Thu, 5 Mar 2026 17:15:33 +0800 Subject: [PATCH 10/31] feat(export): show spinner in load detail in-progress status --- src/pages/ExportPage.scss | 13 +++++++++++++ src/pages/ExportPage.tsx | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 7c227ea..78cb3a1 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -228,6 +228,19 @@ } } +.session-load-detail-status-cell { + display: inline-flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + min-width: 0; +} + +.session-load-detail-status-icon { + color: var(--text-tertiary); + flex-shrink: 0; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 34c1960..b005bb1 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5014,7 +5014,12 @@ function ExportPage() { {sessionLoadDetailRows.map((row) => (
{row.label} - {row.messageCount.statusLabel} + + {row.messageCount.statusLabel} + {row.messageCount.statusLabel.startsWith('加载中') && ( + + )} + {formatLoadDetailTime(row.messageCount.startedAt)} {formatLoadDetailTime(row.messageCount.finishedAt)}
@@ -5034,7 +5039,12 @@ function ExportPage() { {sessionLoadDetailRows.map((row) => (
{row.label} - {row.mediaMetrics.statusLabel} + + {row.mediaMetrics.statusLabel} + {row.mediaMetrics.statusLabel.startsWith('加载中') && ( + + )} + {formatLoadDetailTime(row.mediaMetrics.startedAt)} {formatLoadDetailTime(row.mediaMetrics.finishedAt)}
From 7cc2961538079706115b48b5f54d0d63132e3d26 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:24:14 +0800 Subject: [PATCH 11/31] fix(export): hide finish time until grouped load completes --- src/pages/ExportPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index b005bb1..bd33b92 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -3858,7 +3858,7 @@ function ExportPage() { loaded, statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), startedAt: earliestStart, - finishedAt: latestFinish + finishedAt: loaded >= total ? latestFinish : undefined } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) From db0ebc6c33a8bda63ee5753ac17925f27aabdb62 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:24:28 +0800 Subject: [PATCH 12/31] feat(sns): show loaded vs total posts in author timeline --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/snsService.ts | 52 +++++++++++++++++++++++ src/components/Sns/SnsPostItem.tsx | 65 ++++++++++++++++------------- src/pages/SnsPage.scss | 7 ++++ src/pages/SnsPage.tsx | 66 ++++++++++++++++++++++++++---- src/types/electron.d.ts | 1 + 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 207d3b6..6ca4580 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1517,6 +1517,10 @@ function registerIpcHandlers() { return snsService.getExportStatsFast() }) + ipcMain.handle('sns:getUserPostStats', async (_, username: string) => { + return snsService.getUserPostStats(username) + }) + ipcMain.handle('sns:debugResource', async (_, url: string) => { return snsService.debugResource(url) }) diff --git a/electron/preload.ts b/electron/preload.ts index a323f4c..feb594b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -355,6 +355,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), + getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 1e0be35..9b30dfb 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -506,6 +506,10 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' if (typeof raw !== 'string') return '' @@ -864,6 +868,54 @@ class SnsService { }) } + async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { + const normalizedUsername = this.toOptionalString(username) + if (!normalizedUsername) { + return { success: false, error: '用户名不能为空' } + } + + const escapedUsername = this.escapeSqlString(normalizedUsername) + const primaryResult = await wcdbService.execQuery( + 'sns', + null, + `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` + ) + + if (primaryResult.success) { + const totalPosts = primaryResult.rows && primaryResult.rows.length > 0 + ? this.parseCountValue(primaryResult.rows[0]) + : 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts + } + } + } + + const fallbackResult = await wcdbService.execQuery( + 'sns', + null, + `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = '${escapedUsername}'` + ) + + if (fallbackResult.success) { + const totalPosts = fallbackResult.rows && fallbackResult.rows.length > 0 + ? this.parseCountValue(fallbackResult.rows[0]) + : 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts + } + } + } + + return { success: false, error: primaryResult.error || fallbackResult.error || '统计单个好友朋友圈失败' } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() diff --git a/src/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 980377f..7498b36 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -243,11 +243,12 @@ interface SnsPostItemProps { post: SnsPost onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void onDebug: (post: SnsPost) => void - onDelete?: (postId: string) => void + onDelete?: (postId: string, username: string) => void onOpenAuthorPosts?: (post: SnsPost) => void + hideAuthorMeta?: boolean } -export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts }) => { +export const SnsPostItem: React.FC = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => { const [mediaDeleted, setMediaDeleted] = useState(false) const [dbDeleted, setDbDeleted] = useState(false) const [deleting, setDeleting] = useState(false) @@ -300,7 +301,7 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id) if (r.success) { setDbDeleted(true) - onDelete?.(post.id) + onDelete?.(post.id, post.username) } } finally { setDeleting(false) @@ -315,35 +316,41 @@ export const SnsPostItem: React.FC = ({ post, onPreview, onDeb return ( <>
-
- -
+ {!hideAuthorMeta && ( +
+ +
+ )}
-
- - {formatTime(post.createTime)} -
+ {hideAuthorMeta ? ( + {formatTime(post.createTime)} + ) : ( +
+ + {formatTime(post.createTime)} +
+ )}
{(mediaDeleted || dbDeleted) && ( diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 013eaae..854cc28 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -267,6 +267,13 @@ flex-shrink: 0; } + .post-time-standalone { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.2; + padding-top: 2px; + } + .debug-btn { opacity: 0; transition: opacity 0.2s; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 53d34ad..b3459f3 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -67,6 +67,8 @@ export default function SnsPage() { const [authorTimelineLoading, setAuthorTimelineLoading] = useState(false) const [authorTimelineLoadingMore, setAuthorTimelineLoadingMore] = useState(false) const [authorTimelineHasMore, setAuthorTimelineHasMore] = useState(false) + const [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState(null) + const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -104,6 +106,7 @@ export default function SnsPage() { const authorTimelinePostsRef = useRef([]) const authorTimelineLoadingRef = useRef(false) const authorTimelineRequestTokenRef = useRef(0) + const authorTimelineStatsTokenRef = useRef(0) // Sync posts ref useEffect(() => { @@ -477,12 +480,41 @@ export default function SnsPage() { const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 + authorTimelineStatsTokenRef.current += 1 authorTimelineLoadingRef.current = false setAuthorTimelineTarget(null) setAuthorTimelinePosts([]) setAuthorTimelineLoading(false) setAuthorTimelineLoadingMore(false) setAuthorTimelineHasMore(false) + setAuthorTimelineTotalPosts(null) + setAuthorTimelineStatsLoading(false) + }, []) + + const loadAuthorTimelineTotalPosts = useCallback(async (target: AuthorTimelineTarget) => { + const requestToken = ++authorTimelineStatsTokenRef.current + setAuthorTimelineStatsLoading(true) + setAuthorTimelineTotalPosts(null) + + try { + const result = await window.electronAPI.sns.getUserPostStats(target.username) + if (requestToken !== authorTimelineStatsTokenRef.current) return + + if (result.success && result.data) { + setAuthorTimelineTotalPosts(Math.max(0, Number(result.data.totalPosts || 0))) + } else { + setAuthorTimelineTotalPosts(null) + } + } catch (error) { + console.error('Failed to load author timeline total posts:', error) + if (requestToken === authorTimelineStatsTokenRef.current) { + setAuthorTimelineTotalPosts(null) + } + } finally { + if (requestToken === authorTimelineStatsTokenRef.current) { + setAuthorTimelineStatsLoading(false) + } + } }, []) const loadAuthorTimelinePosts = useCallback(async (target: AuthorTimelineTarget, options: { reset?: boolean } = {}) => { @@ -568,23 +600,28 @@ export default function SnsPage() { setAuthorTimelineTarget(target) setAuthorTimelinePosts([]) setAuthorTimelineHasMore(false) + setAuthorTimelineTotalPosts(null) void loadAuthorTimelinePosts(target, { reset: true }) - }, [loadAuthorTimelinePosts]) + void loadAuthorTimelineTotalPosts(target) + }, [loadAuthorTimelinePosts, loadAuthorTimelineTotalPosts]) const loadMoreAuthorTimeline = useCallback(() => { if (!authorTimelineTarget || authorTimelineLoading || authorTimelineLoadingMore || !authorTimelineHasMore) return void loadAuthorTimelinePosts(authorTimelineTarget, { reset: false }) }, [authorTimelineHasMore, authorTimelineLoading, authorTimelineLoadingMore, authorTimelineTarget, loadAuthorTimelinePosts]) - const handlePostDelete = useCallback((postId: string) => { + const handlePostDelete = useCallback((postId: string, username: string) => { setPosts(prev => { const next = prev.filter(p => p.id !== postId) void persistSnsPageCache({ posts: next }) return next }) setAuthorTimelinePosts(prev => prev.filter(p => p.id !== postId)) + if (authorTimelineTarget && authorTimelineTarget.username === username) { + setAuthorTimelineTotalPosts(prev => prev === null ? null : Math.max(0, prev - 1)) + } void loadOverviewStats() - }, [loadOverviewStats, persistSnsPageCache]) + }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache]) // Initial Load & Listeners useEffect(() => { @@ -626,6 +663,13 @@ export default function SnsPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [authorTimelineTarget, closeAuthorTimeline]) + useEffect(() => { + if (authorTimelineTotalPosts === null) return + if (authorTimelinePosts.length >= authorTimelineTotalPosts) { + setAuthorTimelineHasMore(false) + } + }, [authorTimelinePosts.length, authorTimelineTotalPosts]) + const handleScroll = (e: React.UIEvent) => { const { scrollTop, clientHeight, scrollHeight } = e.currentTarget if (scrollHeight - scrollTop - clientHeight < 400 && hasMore && !loading && !loadingNewer) { @@ -652,12 +696,19 @@ export default function SnsPage() { } const renderAuthorTimelineStats = () => { - if (authorTimelineLoading) return '加载中...' - if (authorTimelinePosts.length === 0) return '暂无朋友圈' + const loadedCount = authorTimelinePosts.length + const loadPart = authorTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : authorTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${authorTimelineTotalPosts} 条` + + if (authorTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + const latest = authorTimelinePosts[0]?.createTime ?? null const earliest = authorTimelinePosts[authorTimelinePosts.length - 1]?.createTime ?? null - const loadedLabel = authorTimelineHasMore ? `已加载 ${authorTimelinePosts.length} 条` : `共 ${authorTimelinePosts.length} 条` - return `${loadedLabel} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` } return ( @@ -858,6 +909,7 @@ export default function SnsPage() { onDebug={(p) => setDebugPost(p)} onDelete={handlePostDelete} onOpenAuthorPosts={openAuthorTimeline} + hideAuthorMeta /> ))}
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 00ce172..0d05be4 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -791,6 +791,7 @@ export interface ElectronAPI { getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> + getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }> From 38af8de4693589f2d98b02c4806e6ac7ad3bc8a3 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:33:41 +0800 Subject: [PATCH 13/31] fix(sns): fallback to userName when user_name count is zero --- electron/services/snsService.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 9b30dfb..a05bb38 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -881,15 +881,15 @@ class SnsService { `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` ) - if (primaryResult.success) { - const totalPosts = primaryResult.rows && primaryResult.rows.length > 0 - ? this.parseCountValue(primaryResult.rows[0]) - : 0 + const primaryTotal = (primaryResult.success && primaryResult.rows && primaryResult.rows.length > 0) + ? this.parseCountValue(primaryResult.rows[0]) + : 0 + if (primaryResult.success && primaryTotal > 0) { return { success: true, data: { username: normalizedUsername, - totalPosts + totalPosts: primaryTotal } } } @@ -901,14 +901,24 @@ class SnsService { ) if (fallbackResult.success) { - const totalPosts = fallbackResult.rows && fallbackResult.rows.length > 0 + const fallbackTotal = fallbackResult.rows && fallbackResult.rows.length > 0 ? this.parseCountValue(fallbackResult.rows[0]) : 0 return { success: true, data: { username: normalizedUsername, - totalPosts + totalPosts: Math.max(primaryTotal, fallbackTotal) + } + } + } + + if (primaryResult.success) { + return { + success: true, + data: { + username: normalizedUsername, + totalPosts: primaryTotal } } } From 3388b7a1221a6fc76553b1435cc6e761599a0c52 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:44:24 +0800 Subject: [PATCH 14/31] fix(sns): derive per-user totals from timeline counts map --- electron/main.ts | 4 ++ electron/preload.ts | 1 + electron/services/snsService.ts | 117 +++++++++++++++++++------------- src/pages/SnsPage.tsx | 7 +- src/types/electron.d.ts | 1 + 5 files changed, 80 insertions(+), 50 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index 6ca4580..21f63a4 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1509,6 +1509,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) diff --git a/electron/preload.ts b/electron/preload.ts index feb594b..41039e2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -353,6 +353,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), + getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), getExportStats: () => ipcRenderer.invoke('sns:getExportStats'), getUserPostStats: (username: string) => ipcRenderer.invoke('sns:getUserPostStats', username), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index a05bb38..2bb2908 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -292,7 +292,9 @@ class SnsService { private contactCache: ContactCacheService private imageCache = new Map() private exportStatsCache: { totalPosts: number; totalFriends: number; myPosts: number | null; updatedAt: number } | null = null + private userPostCountsCache: { counts: Record; updatedAt: number } | null = null private readonly exportStatsCacheTtlMs = 5 * 60 * 1000 + private readonly userPostCountsCacheTtlMs = 5 * 60 * 1000 private lastTimelineFallbackAt = 0 private readonly timelineFallbackCooldownMs = 3 * 60 * 1000 @@ -506,10 +508,6 @@ class SnsService { return Number.isFinite(num) && num > 0 ? Math.floor(num) : 0 } - private escapeSqlString(value: string): string { - return value.replace(/'/g, "''") - } - private pickTimelineUsername(post: any): string { const raw = post?.username ?? post?.user_name ?? post?.userName ?? '' if (typeof raw !== 'string') return '' @@ -868,62 +866,82 @@ class SnsService { }) } + private async getUserPostCountsFromTimeline(): Promise> { + const pageSize = 500 + const counts: Record = {} + let offset = 0 + + for (let round = 0; round < 2000; round++) { + const result = await wcdbService.getSnsTimeline(pageSize, offset, undefined, undefined, 0, 0) + if (!result.success || !Array.isArray(result.timeline)) { + throw new Error(result.error || '获取朋友圈用户总条数失败') + } + + const rows = result.timeline + if (rows.length === 0) break + + for (const row of rows) { + const username = this.pickTimelineUsername(row) + if (!username) continue + counts[username] = (counts[username] || 0) + 1 + } + + if (rows.length < pageSize) break + offset += rows.length + } + + return counts + } + + async getUserPostCounts(options?: { + preferCache?: boolean + }): Promise<{ success: boolean; counts?: Record; error?: string }> { + const preferCache = options?.preferCache ?? true + const now = Date.now() + + try { + if ( + preferCache && + this.userPostCountsCache && + now - this.userPostCountsCache.updatedAt <= this.userPostCountsCacheTtlMs + ) { + return { success: true, counts: this.userPostCountsCache.counts } + } + + const counts = await this.getUserPostCountsFromTimeline() + this.userPostCountsCache = { + counts, + updatedAt: Date.now() + } + return { success: true, counts } + } catch (error) { + console.error('[SnsService] getUserPostCounts failed:', error) + if (this.userPostCountsCache) { + return { success: true, counts: this.userPostCountsCache.counts } + } + return { success: false, error: String(error) } + } + } + async getUserPostStats(username: string): Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> { const normalizedUsername = this.toOptionalString(username) if (!normalizedUsername) { return { success: false, error: '用户名不能为空' } } - const escapedUsername = this.escapeSqlString(normalizedUsername) - const primaryResult = await wcdbService.execQuery( - 'sns', - null, - `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE user_name = '${escapedUsername}'` - ) - - const primaryTotal = (primaryResult.success && primaryResult.rows && primaryResult.rows.length > 0) - ? this.parseCountValue(primaryResult.rows[0]) - : 0 - if (primaryResult.success && primaryTotal > 0) { + const countsResult = await this.getUserPostCounts({ preferCache: true }) + if (countsResult.success) { + const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 return { success: true, data: { username: normalizedUsername, - totalPosts: primaryTotal + totalPosts: Math.max(0, Number(totalPosts || 0)) } } } - const fallbackResult = await wcdbService.execQuery( - 'sns', - null, - `SELECT COUNT(1) AS total FROM SnsTimeLine WHERE userName = '${escapedUsername}'` - ) - - if (fallbackResult.success) { - const fallbackTotal = fallbackResult.rows && fallbackResult.rows.length > 0 - ? this.parseCountValue(fallbackResult.rows[0]) - : 0 - return { - success: true, - data: { - username: normalizedUsername, - totalPosts: Math.max(primaryTotal, fallbackTotal) - } - } - } - - if (primaryResult.success) { - return { - success: true, - data: { - username: normalizedUsername, - totalPosts: primaryTotal - } - } - } - - return { success: false, error: primaryResult.error || fallbackResult.error || '统计单个好友朋友圈失败' } + return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } } // 安装朋友圈删除拦截 @@ -943,7 +961,12 @@ class SnsService { // 从数据库直接删除朋友圈记录 async deleteSnsPost(postId: string): Promise<{ success: boolean; error?: string }> { - return wcdbService.deleteSnsPost(postId) + const result = await wcdbService.deleteSnsPost(postId) + if (result.success) { + this.userPostCountsCache = null + this.exportStatsCache = null + } + return result } /** diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index b3459f3..5edb5b6 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -497,11 +497,12 @@ export default function SnsPage() { setAuthorTimelineTotalPosts(null) try { - const result = await window.electronAPI.sns.getUserPostStats(target.username) + const result = await window.electronAPI.sns.getUserPostCounts() if (requestToken !== authorTimelineStatsTokenRef.current) return - if (result.success && result.data) { - setAuthorTimelineTotalPosts(Math.max(0, Number(result.data.totalPosts || 0))) + if (result.success && result.counts) { + const totalPosts = result.counts[target.username] ?? 0 + setAuthorTimelineTotalPosts(Math.max(0, Number(totalPosts || 0))) } else { setAuthorTimelineTotalPosts(null) } diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 0d05be4..72aaa57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -789,6 +789,7 @@ export interface ElectronAPI { onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }> getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }> + getUserPostCounts: () => Promise<{ success: boolean; counts?: Record; error?: string }> getExportStatsFast: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getExportStats: () => Promise<{ success: boolean; data?: { totalPosts: number; totalFriends: number; myPosts: number | null }; error?: string }> getUserPostStats: (username: string) => Promise<{ success: boolean; data?: { username: string; totalPosts: number }; error?: string }> From 9dd5ee236522c64c9dce4a20482c3a52f6e5cdc9 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 17:44:32 +0800 Subject: [PATCH 15/31] fix(export): align media load progress with visible loaded state --- src/pages/ExportPage.scss | 8 ++ src/pages/ExportPage.tsx | 165 ++++++++++++++++++++++++++++++-------- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 78cb3a1..4ac3e47 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -241,6 +241,14 @@ flex-shrink: 0; } +.session-load-detail-progress-pulse { + color: var(--text-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.1px; + flex-shrink: 0; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index bd33b92..c079795 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -711,6 +711,15 @@ interface SessionLoadTraceState { mediaMetrics: SessionLoadStageState } +interface SessionLoadStageSummary { + total: number + loaded: number + statusLabel: string + startedAt?: number + finishedAt?: number + latestProgressAt?: number +} + const withTimeout = async (promise: Promise, timeoutMs: number): Promise => { let timer: ReturnType | null = null try { @@ -1279,6 +1288,7 @@ function ExportPage() { const [isSessionCountStageReady, setIsSessionCountStageReady] = useState(false) const [sessionContentMetrics, setSessionContentMetrics] = useState>({}) const [sessionLoadTraceMap, setSessionLoadTraceMap] = useState>({}) + const [sessionLoadProgressPulseMap, setSessionLoadProgressPulseMap] = useState>({}) const [contactsLoadTimeoutMs, setContactsLoadTimeoutMs] = useState(DEFAULT_CONTACTS_LOAD_TIMEOUT_MS) const [contactsLoadSession, setContactsLoadSession] = useState(null) const [contactsLoadIssue, setContactsLoadIssue] = useState(null) @@ -1382,6 +1392,7 @@ function ExportPage() { const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) const sessionPreciseRefreshAtRef = useRef>({}) + const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) const sessionMediaMetricQueuedSetRef = useRef>(new Set()) const sessionMediaMetricLoadingSetRef = useRef>(new Set()) @@ -2352,6 +2363,8 @@ function ExportPage() { setSessionMessageCounts({}) setSessionContentMetrics({}) setSessionLoadTraceMap({}) + setSessionLoadProgressPulseMap({}) + sessionLoadProgressSnapshotRef.current = {} setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2430,9 +2443,11 @@ function ExportPage() { } return acc }, {}) - const cachedContentMetricSessionIds = Object.keys(cachedContentMetrics) - if (cachedContentMetricSessionIds.length > 0) { - patchSessionLoadTraceStage(cachedContentMetricSessionIds, 'mediaMetrics', 'done') + const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics) + .filter(([, metric]) => hasCompleteSessionMediaMetric(metric)) + .map(([sessionId]) => sessionId) + if (cachedContentMetricReadySessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') } if (isStale()) return @@ -3828,16 +3843,22 @@ function ExportPage() { const summarizeLoadTraceForTab = useCallback(( sessionIds: string[], stageKey: keyof SessionLoadTraceState - ) => { + ): SessionLoadStageSummary => { const total = sessionIds.length let loaded = 0 let hasStarted = false let earliestStart: number | undefined let latestFinish: number | undefined + let latestProgressAt: number | undefined for (const sessionId of sessionIds) { const stage = sessionLoadTraceMap[sessionId]?.[stageKey] if (stage?.status === 'done') { loaded += 1 + if (typeof stage.finishedAt === 'number') { + latestProgressAt = latestProgressAt === undefined + ? stage.finishedAt + : Math.max(latestProgressAt, stage.finishedAt) + } } if (stage?.status === 'loading' || stage?.status === 'failed' || typeof stage?.startedAt === 'number') { hasStarted = true @@ -3858,7 +3879,8 @@ function ExportPage() { loaded, statusLabel: getLoadDetailStatusLabel(loaded, total, hasStarted), startedAt: earliestStart, - finishedAt: loaded >= total ? latestFinish : undefined + finishedAt: loaded >= total ? latestFinish : undefined, + latestProgressAt } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) @@ -3875,6 +3897,67 @@ function ExportPage() { }) }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + const formatLoadDetailPulseTime = useCallback((value?: number): string => { + if (!value || !Number.isFinite(value)) return '--' + return new Date(value).toLocaleTimeString('zh-CN', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + }, []) + + useEffect(() => { + const previousSnapshot = sessionLoadProgressSnapshotRef.current + const nextSnapshot: Record = {} + const resetKeys: string[] = [] + const updates: Array<{ key: string; at: number; delta: number }> = [] + const stageKeys: Array = ['messageCount', 'mediaMetrics'] + + for (const row of sessionLoadDetailRows) { + for (const stageKey of stageKeys) { + const summary = row[stageKey] + const key = `${stageKey}:${row.tab}` + const loaded = Number.isFinite(summary.loaded) ? Math.max(0, Math.floor(summary.loaded)) : 0 + const total = Number.isFinite(summary.total) ? Math.max(0, Math.floor(summary.total)) : 0 + nextSnapshot[key] = { loaded, total } + + const previous = previousSnapshot[key] + if (!previous || previous.total !== total || loaded < previous.loaded) { + resetKeys.push(key) + continue + } + if (loaded > previous.loaded) { + updates.push({ + key, + at: summary.latestProgressAt || Date.now(), + delta: loaded - previous.loaded + }) + } + } + } + + sessionLoadProgressSnapshotRef.current = nextSnapshot + if (resetKeys.length === 0 && updates.length === 0) return + + setSessionLoadProgressPulseMap(prev => { + let changed = false + const next = { ...prev } + for (const key of resetKeys) { + if (!(key in next)) continue + delete next[key] + changed = true + } + for (const update of updates) { + const previous = next[update.key] + if (previous && previous.at === update.at && previous.delta === update.delta) continue + next[update.key] = { at: update.at, delta: update.delta } + changed = true + } + return changed ? next : prev + }) + }, [sessionLoadDetailRows]) + useEffect(() => { contactsVirtuosoRef.current?.scrollToIndex({ index: 0, align: 'start' }) setIsContactsListAtTop(true) @@ -4482,7 +4565,6 @@ function ExportPage() { const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { const normalized = normalizeMessageCount(value) if (!canExport) return { state: 'na', text: '--' } - if (!isSessionCountStageReady) return { state: 'loading' } if (typeof normalized === 'number') { return { state: 'value', text: normalized.toLocaleString('zh-CN') } } @@ -4619,7 +4701,6 @@ function ExportPage() { sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, - isSessionCountStageReady, toggleSelectSession ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { @@ -5011,19 +5092,28 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.messageCount.statusLabel} - {row.messageCount.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.messageCount.startedAt)} - {formatLoadDetailTime(row.messageCount.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`messageCount:${row.tab}`] + const isLoading = row.messageCount.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.messageCount.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.messageCount.startedAt)} + {formatLoadDetailTime(row.messageCount.finishedAt)} +
+ ) + })}
@@ -5036,19 +5126,28 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => ( -
- {row.label} - - {row.mediaMetrics.statusLabel} - {row.mediaMetrics.statusLabel.startsWith('加载中') && ( - - )} - - {formatLoadDetailTime(row.mediaMetrics.startedAt)} - {formatLoadDetailTime(row.mediaMetrics.finishedAt)} -
- ))} + {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`mediaMetrics:${row.tab}`] + const isLoading = row.mediaMetrics.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.mediaMetrics.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.mediaMetrics.startedAt)} + {formatLoadDetailTime(row.mediaMetrics.finishedAt)} +
+ ) + })}
From c301f36912a9acd9dbc1abd638f087eff38b5c57 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:08:09 +0800 Subject: [PATCH 16/31] feat(export): add sns count and timeline popup in session detail --- src/pages/ExportPage.scss | 253 +++++++++++++++++++++++- src/pages/ExportPage.tsx | 393 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 643 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4ac3e47..034cd9b 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -120,7 +120,7 @@ } .session-load-detail-modal { - width: min(760px, 100%); + width: min(820px, 100%); max-height: min(78vh, 860px); overflow: hidden; border-radius: 14px; @@ -200,14 +200,14 @@ .session-load-detail-row { display: grid; - grid-template-columns: 1.1fr 1fr 0.8fr 0.8fr; + grid-template-columns: minmax(76px, 0.78fr) minmax(260px, 1.55fr) minmax(84px, 0.74fr) minmax(84px, 0.74fr); gap: 10px; align-items: center; padding: 9px 12px; font-size: 12px; color: var(--text-secondary); border-bottom: 1px solid color-mix(in srgb, var(--border-color) 66%, transparent); - min-width: 540px; + min-width: 620px; &:last-child { border-bottom: none; @@ -230,10 +230,14 @@ .session-load-detail-status-cell { display: inline-flex; + flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; min-width: 0; + overflow: visible !important; + text-overflow: clip !important; + white-space: normal !important; } .session-load-detail-status-icon { @@ -245,6 +249,7 @@ color: var(--text-tertiary); font-size: 11px; font-variant-numeric: tabular-nums; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; letter-spacing: 0.1px; flex-shrink: 0; } @@ -2038,6 +2043,10 @@ } } + .detail-sns-entry-btn { + white-space: nowrap; + } + .copy-btn { display: flex; align-items: center; @@ -2149,6 +2158,218 @@ } } +.export-session-sns-overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: rgba(15, 23, 42, 0.38); +} + +.export-session-sns-dialog { + width: min(760px, 100%); + max-height: min(86vh, 860px); + border-radius: 14px; + border: 1px solid var(--border-color); + background: var(--bg-secondary-solid, #ffffff); + box-shadow: 0 22px 46px rgba(0, 0, 0, 0.24); + display: flex; + flex-direction: column; + overflow: hidden; + + .sns-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--border-color); + } + + .sns-dialog-header-main { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .sns-dialog-avatar { + width: 42px; + height: 42px; + border-radius: 10px; + background: linear-gradient(135deg, var(--primary), var(--primary-hover)); + overflow: hidden; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + span { + color: #fff; + font-size: 14px; + font-weight: 600; + } + } + + .sns-dialog-meta { + min-width: 0; + + h4 { + margin: 0; + font-size: 15px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .sns-dialog-username { + margin-top: 2px; + font-size: 12px; + color: var(--text-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-stats { + margin-top: 4px; + font-size: 12px; + color: var(--text-secondary); + } + + .close-btn { + border: none; + background: transparent; + color: var(--text-secondary); + width: 28px; + height: 28px; + border-radius: 7px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + } + + .sns-dialog-body { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .sns-post-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .sns-post-card { + border: 1px solid var(--border-color); + background: var(--bg-primary); + border-radius: 10px; + padding: 10px 11px; + } + + .sns-post-time { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 6px; + } + + .sns-post-content { + white-space: pre-wrap; + word-break: break-word; + font-size: 13px; + color: var(--text-primary); + line-height: 1.55; + } + + .sns-post-media-grid { + margin-top: 8px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .sns-post-media-item { + border: none; + padding: 0; + border-radius: 8px; + overflow: hidden; + background: var(--bg-secondary); + position: relative; + cursor: pointer; + aspect-ratio: 1 / 1; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + + .sns-post-media-video-tag { + position: absolute; + right: 6px; + bottom: 6px; + background: rgba(0, 0, 0, 0.64); + color: #fff; + border-radius: 5px; + font-size: 11px; + line-height: 1; + padding: 3px 5px; + } + + .sns-dialog-status { + padding: 16px 0; + text-align: center; + color: var(--text-secondary); + font-size: 13px; + + &.empty { + color: var(--text-tertiary); + } + } + + .sns-dialog-load-more { + display: block; + margin: 12px auto 0; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + cursor: pointer; + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &:hover:not(:disabled) { + background: var(--bg-hover); + } + } +} + .table-state { display: flex; align-items: center; @@ -2862,6 +3083,15 @@ margin-left: 0; } + .session-load-detail-modal { + width: min(94vw, 820px); + } + + .session-load-detail-row { + grid-template-columns: minmax(68px, 0.72fr) minmax(232px, 1.6fr) minmax(80px, 0.72fr) minmax(80px, 0.72fr); + min-width: 560px; + } + .table-wrap { --contacts-message-col-width: 104px; --contacts-media-col-width: 62px; @@ -2961,4 +3191,21 @@ .export-session-detail-panel { width: calc(100vw - 12px); } + + .export-session-sns-overlay { + padding: 12px 8px; + } + + .export-session-sns-dialog { + width: min(100vw - 16px, 760px); + max-height: calc(100vh - 24px); + + .sns-dialog-header { + padding: 12px; + } + + .sns-dialog-body { + padding: 10px 10px 12px; + } + } } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c079795..21d9f82 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,7 @@ import { onOpenSingleExport } from '../services/exportBridge' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import type { SnsPost } from '../types/sns' import './ExportPage.scss' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -422,6 +423,20 @@ const formatYmdHmDateTime = (timestamp?: number): string => { return `${y}-${m}-${day} ${h}:${min}` } +const isSingleContactSession = (sessionId: string): boolean => { + const normalized = String(sessionId || '').trim() + if (!normalized) return false + if (normalized.includes('@chatroom')) return false + if (normalized.startsWith('gh_')) return false + return true +} + +const isSnsVideoMediaUrl = (url?: string): boolean => { + if (!url) return false + const lower = url.toLowerCase() + return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') +} + const formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -661,6 +676,12 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -1302,6 +1323,15 @@ function ExportPage() { const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false) const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false) const [copiedDetailField, setCopiedDetailField] = useState(null) + const [snsUserPostCounts, setSnsUserPostCounts] = useState>({}) + const [snsUserPostCountsStatus, setSnsUserPostCountsStatus] = useState<'idle' | 'loading' | 'ready' | 'error'>('idle') + const [sessionSnsTimelineTarget, setSessionSnsTimelineTarget] = useState(null) + const [sessionSnsTimelinePosts, setSessionSnsTimelinePosts] = useState([]) + const [sessionSnsTimelineLoading, setSessionSnsTimelineLoading] = useState(false) + const [sessionSnsTimelineLoadingMore, setSessionSnsTimelineLoadingMore] = useState(false) + const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) + const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) + const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1391,6 +1421,9 @@ function ExportPage() { const isLoadingSessionCountsRef = useRef(false) const activeTabRef = useRef('private') const detailStatsPriorityRef = useRef(false) + const sessionSnsTimelinePostsRef = useRef([]) + const sessionSnsTimelineLoadingRef = useRef(false) + const sessionSnsTimelineRequestTokenRef = useRef(0) const sessionPreciseRefreshAtRef = useRef>({}) const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) @@ -1774,6 +1807,10 @@ function ExportPage() { hasSeededSnsStatsRef.current = hasSeededSnsStats }, [hasSeededSnsStats]) + useEffect(() => { + sessionSnsTimelinePostsRef.current = sessionSnsTimelinePosts + }, [sessionSnsTimelinePosts]) + const preselectSessionIds = useMemo(() => { const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const rawList = Array.isArray(state?.preselectSessionIds) @@ -1919,6 +1956,177 @@ function ExportPage() { } }, []) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + if (snsUserPostCountsStatus === 'loading') return + if (!options?.force && snsUserPostCountsStatus === 'ready') return + + setSnsUserPostCountsStatus('loading') + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (result.success && result.counts) { + const normalized: Record = {} + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalized[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } + setSnsUserPostCounts(normalized) + setSnsUserPostCountsStatus('ready') + return + } + + setSnsUserPostCountsStatus('error') + } catch (error) { + console.error('加载朋友圈用户条数失败:', error) + setSnsUserPostCountsStatus('error') + } + }, [snsUserPostCountsStatus]) + + const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { + const reset = Boolean(options?.reset) + if (sessionSnsTimelineLoadingRef.current) return + + sessionSnsTimelineLoadingRef.current = true + if (reset) { + setSessionSnsTimelineLoading(true) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + } else { + setSessionSnsTimelineLoadingMore(true) + } + + const requestToken = ++sessionSnsTimelineRequestTokenRef.current + + try { + const limit = 20 + let endTime: number | undefined + if (!reset && sessionSnsTimelinePostsRef.current.length > 0) { + endTime = sessionSnsTimelinePostsRef.current[sessionSnsTimelinePostsRef.current.length - 1].createTime - 1 + } + + const result = await window.electronAPI.sns.getTimeline(limit, 0, [target.username], '', undefined, endTime) + if (requestToken !== sessionSnsTimelineRequestTokenRef.current) return + + if (!result.success || !Array.isArray(result.timeline)) { + if (reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + return + } + + const timeline = [...(result.timeline as SnsPost[])].sort((a, b) => b.createTime - a.createTime) + if (reset) { + setSessionSnsTimelinePosts(timeline) + setSessionSnsTimelineHasMore(timeline.length >= limit) + return + } + + const existingIds = new Set(sessionSnsTimelinePostsRef.current.map((post) => post.id)) + const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id)) + if (uniqueOlder.length > 0) { + const merged = [...sessionSnsTimelinePostsRef.current, ...uniqueOlder].sort((a, b) => b.createTime - a.createTime) + setSessionSnsTimelinePosts(merged) + } + if (timeline.length < limit) { + setSessionSnsTimelineHasMore(false) + } + } catch (error) { + console.error('加载联系人朋友圈失败:', error) + if (requestToken === sessionSnsTimelineRequestTokenRef.current && reset) { + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + } + } finally { + if (requestToken === sessionSnsTimelineRequestTokenRef.current) { + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + } + } + }, []) + + const closeSessionSnsTimeline = useCallback(() => { + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + setSessionSnsTimelineTarget(target) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineLoading(false) + + if (snsUserPostCountsStatus === 'ready') { + const count = Number(snsUserPostCounts[normalizedSessionId] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) + setSessionSnsTimelineStatsLoading(false) + } else { + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(true) + } + + void loadSessionSnsTimelinePosts(target, { reset: true }) + void loadSnsUserPostCounts() + }, [ + loadSessionSnsTimelinePosts, + loadSnsUserPostCounts, + sessionDetail, + snsUserPostCounts, + snsUserPostCountsStatus + ]) + + const loadMoreSessionSnsTimeline = useCallback(() => { + if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return + void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) + }, [ + loadSessionSnsTimelinePosts, + sessionSnsTimelineHasMore, + sessionSnsTimelineLoading, + sessionSnsTimelineLoadingMore, + sessionSnsTimelineTarget + ]) + + const renderSessionSnsTimelineStats = useCallback((): string => { + const loadedCount = sessionSnsTimelinePosts.length + const loadPart = sessionSnsTimelineStatsLoading + ? `已加载 ${loadedCount} / 总数统计中...` + : sessionSnsTimelineTotalPosts === null + ? `已加载 ${loadedCount} 条` + : `已加载 ${loadedCount} / 共 ${sessionSnsTimelineTotalPosts} 条` + + if (sessionSnsTimelineLoading && loadedCount === 0) return `${loadPart} | 加载中...` + if (loadedCount === 0) return loadPart + + const latest = sessionSnsTimelinePosts[0]?.createTime + const earliest = sessionSnsTimelinePosts[sessionSnsTimelinePosts.length - 1]?.createTime + const rangeText = `${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}` + return `${loadPart} | ${rangeText}` + }, [ + sessionSnsTimelineLoading, + sessionSnsTimelinePosts, + sessionSnsTimelineStatsLoading, + sessionSnsTimelineTotalPosts + ]) + const mergeSessionContentMetrics = useCallback((input: Record) => { const entries = Object.entries(input) if (entries.length === 0) return @@ -4081,6 +4289,27 @@ function ExportPage() { .slice(0, 20) }, [sessionDetail?.wxid, exportRecordsBySession]) + const sessionDetailSupportsSnsTimeline = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + return isSingleContactSession(sessionId) + }, [sessionDetail?.wxid]) + + const sessionDetailSnsCountLabel = useMemo(() => { + const sessionId = String(sessionDetail?.wxid || '').trim() + if (!sessionId || !sessionDetailSupportsSnsTimeline) return '朋友圈:0条' + + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + return '朋友圈:统计中...' + } + if (snsUserPostCountsStatus === 'error') { + return '朋友圈:统计失败' + } + + const count = Number(snsUserPostCounts[sessionId] || 0) + const normalized = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0 + return `朋友圈:${normalized}条` + }, [sessionDetail?.wxid, sessionDetailSupportsSnsTimeline, snsUserPostCounts, snsUserPostCountsStatus]) + const applySessionDetailStats = useCallback(( sessionId: string, metric: SessionExportMetric, @@ -4371,14 +4600,58 @@ function ExportPage() { } }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + useEffect(() => { + if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + }, [ + loadSnsUserPostCounts, + sessionDetailSupportsSnsTimeline, + showSessionDetailPanel, + snsUserPostCountsStatus + ]) + + useEffect(() => { + if (!sessionSnsTimelineTarget) return + if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { + setSessionSnsTimelineStatsLoading(true) + return + } + if (snsUserPostCountsStatus === 'ready') { + const total = Number(snsUserPostCounts[sessionSnsTimelineTarget.username] || 0) + setSessionSnsTimelineTotalPosts(Number.isFinite(total) ? Math.max(0, Math.floor(total)) : 0) + setSessionSnsTimelineStatsLoading(false) + return + } + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, [sessionSnsTimelineTarget, snsUserPostCounts, snsUserPostCountsStatus]) + + useEffect(() => { + if (sessionSnsTimelineTotalPosts === null) return + if (sessionSnsTimelinePosts.length >= sessionSnsTimelineTotalPosts) { + setSessionSnsTimelineHasMore(false) + } + }, [sessionSnsTimelinePosts.length, sessionSnsTimelineTotalPosts]) + const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 detailStatsPriorityRef.current = false + sessionSnsTimelineRequestTokenRef.current += 1 + sessionSnsTimelineLoadingRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) setIsRefreshingSessionDetailStats(false) setIsLoadingSessionRelationStats(false) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) }, []) const openSessionDetail = useCallback((sessionId: string) => { @@ -4410,6 +4683,17 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [showSessionLoadDetailModal]) + useEffect(() => { + if (!sessionSnsTimelineTarget) return + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + closeSessionSnsTimeline() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [closeSessionSnsTimeline, sessionSnsTimelineTarget]) + const handleCopyDetailField = useCallback(async (text: string, field: string) => { try { await navigator.clipboard.writeText(text) @@ -5228,6 +5512,21 @@ function ExportPage() {
)} + {sessionDetailSupportsSnsTimeline && ( +
+ + 朋友圈 + + + +
+ )}
@@ -5454,6 +5753,100 @@ function ExportPage() {
)} + + {sessionSnsTimelineTarget && ( +
+
event.stopPropagation()} + > +
+
+
+ {sessionSnsTimelineTarget.avatarUrl ? ( + + ) : ( + {getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)} + )} +
+
+

{sessionSnsTimelineTarget.displayName}

+
@{sessionSnsTimelineTarget.username}
+
{renderSessionSnsTimelineStats()}
+
+
+ +
+ +
+ {sessionSnsTimelinePosts.length > 0 && ( +
+ {sessionSnsTimelinePosts.map((post) => ( +
+
{formatYmdHmDateTime(post.createTime * 1000)}
+ {post.contentDesc &&
{post.contentDesc}
} + {Array.isArray(post.media) && post.media.length > 0 && ( +
+ {post.media.slice(0, 9).map((media, mediaIndex) => { + const mediaUrl = String(media?.url || media?.thumb || '') + const previewUrl = String(media?.thumb || media?.url || '') + if (!mediaUrl || !previewUrl) return null + const isVideo = isSnsVideoMediaUrl(mediaUrl) + return ( + + ) + })} +
+ )} +
+ ))} +
+ )} + + {sessionSnsTimelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && ( + + )} +
+
+
+ )}
From d5dbcd3f8077a83e2e786e7e02fa1ee72098b36f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:27:30 +0800 Subject: [PATCH 17/31] fix(export): align sns timeline dialog with sns page rendering --- src/pages/ExportPage.scss | 8 +++++ src/pages/ExportPage.tsx | 63 +++++++++++++-------------------------- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 034cd9b..80cc66e 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2273,6 +2273,14 @@ padding: 12px 14px 14px; } + .export-session-sns-posts-list { + gap: 14px; + } + + .post-header-actions { + display: none; + } + .sns-post-list { display: flex; flex-direction: column; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 21d9f82..c32f5e4 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,7 @@ import { onOpenSingleExport } from '../services/exportBridge' import { useContactTypeCountsStore } from '../stores/contactTypeCountsStore' +import { SnsPostItem } from '../components/Sns/SnsPostItem' import type { SnsPost } from '../types/sns' import './ExportPage.scss' @@ -431,12 +432,6 @@ const isSingleContactSession = (sessionId: string): boolean => { return true } -const isSnsVideoMediaUrl = (url?: string): boolean => { - if (!url) return false - const lower = url.toLowerCase() - return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb') -} - const formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -2085,7 +2080,7 @@ function ExportPage() { } void loadSessionSnsTimelinePosts(target, { reset: true }) - void loadSnsUserPostCounts() + void loadSnsUserPostCounts({ force: true }) }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, @@ -4658,8 +4653,11 @@ function ExportPage() { if (!sessionId) return detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) + if (isSingleContactSession(sessionId)) { + void loadSnsUserPostCounts({ force: true }) + } void loadSessionDetail(sessionId) - }, [loadSessionDetail]) + }, [loadSessionDetail, loadSnsUserPostCounts]) useEffect(() => { if (!showSessionDetailPanel) return @@ -5785,42 +5783,21 @@ function ExportPage() {
{sessionSnsTimelinePosts.length > 0 && ( -
+
{sessionSnsTimelinePosts.map((post) => ( -
-
{formatYmdHmDateTime(post.createTime * 1000)}
- {post.contentDesc &&
{post.contentDesc}
} - {Array.isArray(post.media) && post.media.length > 0 && ( -
- {post.media.slice(0, 9).map((media, mediaIndex) => { - const mediaUrl = String(media?.url || media?.thumb || '') - const previewUrl = String(media?.thumb || media?.url || '') - if (!mediaUrl || !previewUrl) return null - const isVideo = isSnsVideoMediaUrl(mediaUrl) - return ( - - ) - })} -
- )} -
+ { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + hideAuthorMeta + /> ))}
)} From 63fd42ff052afeca3396440d95806ba262e53a36 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 18:50:46 +0800 Subject: [PATCH 18/31] feat(sns): incremental contact post-count ranking in filter list --- src/components/Sns/SnsFilterPanel.tsx | 36 ++++- src/pages/SnsPage.scss | 42 +++++ src/pages/SnsPage.tsx | 224 ++++++++++++++++++++++++-- 3 files changed, 289 insertions(+), 13 deletions(-) diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 35e23fe..788ac3d 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -1,5 +1,5 @@ -import React, { useState } from 'react' -import { Search, Calendar, User, X, Filter, Check } from 'lucide-react' +import React from 'react' +import { Search, Calendar, User, X, Loader2 } from 'lucide-react' import { Avatar } from '../Avatar' // import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved @@ -7,6 +7,14 @@ interface Contact { username: string displayName: string avatarUrl?: string + postCount?: number + postCountStatus?: 'idle' | 'loading' | 'ready' +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsFilterPanelProps { @@ -21,6 +29,7 @@ interface SnsFilterPanelProps { contactSearch: string setContactSearch: (val: string) => void loading?: boolean + contactsCountProgress?: ContactsCountProgress } export const SnsFilterPanel: React.FC = ({ @@ -34,11 +43,12 @@ export const SnsFilterPanel: React.FC = ({ contacts, contactSearch, setContactSearch, - loading + loading, + contactsCountProgress }) => { const filteredContacts = contacts.filter(c => - c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) || + (c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) || c.username.toLowerCase().includes(contactSearch.toLowerCase()) ) @@ -152,8 +162,17 @@ export const SnsFilterPanel: React.FC = ({ )}
+ {contactsCountProgress && contactsCountProgress.total > 0 && ( +
+ {contactsCountProgress.running + ? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}` + : `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`} +
+ )} +
{filteredContacts.map(contact => { + const isPostCountReady = contact.postCountStatus === 'ready' return (
= ({
{contact.displayName}
+
+ {isPostCountReady ? ( + {Math.max(0, Math.floor(Number(contact.postCount || 0)))}条 + ) : ( + + + + )} +
) })} diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index 854cc28..34febde 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1098,6 +1098,14 @@ } } + .contact-count-progress { + padding: 8px 16px 10px; + font-size: 11px; + color: var(--text-tertiary); + border-bottom: 1px dashed color-mix(in srgb, var(--border-color) 70%, transparent); + font-variant-numeric: tabular-nums; + } + .contact-list-scroll { flex: 1; overflow-y: auto; @@ -1175,6 +1183,40 @@ text-overflow: ellipsis; } } + + .contact-post-count-wrap { + margin-left: 8px; + min-width: 46px; + display: flex; + justify-content: flex-end; + align-items: center; + flex-shrink: 0; + } + + .contact-post-count { + font-size: 12px; + color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .contact-post-count-loading { + color: var(--text-tertiary); + display: inline-flex; + align-items: center; + justify-content: center; + + .spinning { + animation: spin 0.8s linear infinite; + } + } + + &.selected { + .contact-post-count { + color: var(--primary); + font-weight: 600; + } + } } } diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 5edb5b6..4cb4a2b 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -11,12 +11,25 @@ import * as configService from '../services/config' const SNS_PAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000 const SNS_PAGE_CACHE_POST_LIMIT = 200 const SNS_PAGE_CACHE_SCOPE_FALLBACK = '__default__' +const CONTACT_COUNT_SORT_DEBOUNCE_MS = 200 +const CONTACT_COUNT_BATCH_SIZE = 10 + +type ContactPostCountStatus = 'idle' | 'loading' | 'ready' interface Contact { username: string displayName: string avatarUrl?: string type?: 'friend' | 'former_friend' | 'sns_only' + lastSessionTimestamp?: number + postCount?: number + postCountStatus?: ContactPostCountStatus +} + +interface ContactsCountProgress { + resolved: number + total: number + running: boolean } interface SnsOverviewStats { @@ -58,6 +71,11 @@ export default function SnsPage() { const [contacts, setContacts] = useState([]) const [contactSearch, setContactSearch] = useState('') const [contactsLoading, setContactsLoading] = useState(false) + const [contactsCountProgress, setContactsCountProgress] = useState({ + resolved: 0, + total: 0, + running: false + }) // UI states const [showJumpDialog, setShowJumpDialog] = useState(false) @@ -103,6 +121,8 @@ export default function SnsPage() { const cacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) + const contactsCountHydrationTokenRef = useRef(0) + const contactsCountBatchTimerRef = useRef(null) const authorTimelinePostsRef = useRef([]) const authorTimelineLoadingRef = useRef(false) const authorTimelineRequestTokenRef = useRef(0) @@ -165,6 +185,31 @@ export default function SnsPage() { .trim() } + const normalizePostCount = useCallback((value: unknown): number => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 0 + return Math.max(0, Math.floor(numeric)) + }, []) + + const compareContactsForRanking = useCallback((a: Contact, b: Contact): number => { + const aReady = a.postCountStatus === 'ready' + const bReady = b.postCountStatus === 'ready' + if (aReady && bReady) { + const countDiff = normalizePostCount(b.postCount) - normalizePostCount(a.postCount) + if (countDiff !== 0) return countDiff + } else if (aReady !== bReady) { + return aReady ? -1 : 1 + } + + const tsDiff = Number(b.lastSessionTimestamp || 0) - Number(a.lastSessionTimestamp || 0) + if (tsDiff !== 0) return tsDiff + return (a.displayName || a.username).localeCompare((b.displayName || b.username), 'zh-Hans-CN') + }, [normalizePostCount]) + + const sortContactsForRanking = useCallback((input: Contact[]): Contact[] => { + return [...input].sort(compareContactsForRanking) + }, [compareContactsForRanking]) + const isDefaultViewNow = useCallback(() => { return selectedUsernamesRef.current.length === 0 && !searchKeywordRef.current.trim() && !jumpTargetDateRef.current }, []) @@ -423,13 +468,145 @@ export default function SnsPage() { } }, [jumpTargetDate, persistSnsPageCache, searchKeyword, selectedUsernames]) - // Load Contacts(仅加载好友/曾经好友,不再统计朋友圈条数) + const stopContactsCountHydration = useCallback((resetProgress = false) => { + contactsCountHydrationTokenRef.current += 1 + if (contactsCountBatchTimerRef.current) { + window.clearTimeout(contactsCountBatchTimerRef.current) + contactsCountBatchTimerRef.current = null + } + if (resetProgress) { + setContactsCountProgress({ + resolved: 0, + total: 0, + running: false + }) + } else { + setContactsCountProgress((prev) => ({ ...prev, running: false })) + } + }, []) + + const hydrateContactPostCounts = useCallback(async (usernames: string[]) => { + const targets = usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + stopContactsCountHydration(true) + if (targets.length === 0) return + + const runToken = ++contactsCountHydrationTokenRef.current + const totalTargets = targets.length + const targetSet = new Set(targets) + + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!targetSet.has(contact.username)) return contact + if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact + changed = true + return { + ...contact, + postCount: undefined, + postCountStatus: 'loading' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev + }) + setContactsCountProgress({ + resolved: 0, + total: totalTargets, + running: true + }) + + let normalizedCounts: Record = {} + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (runToken !== contactsCountHydrationTokenRef.current) return + if (result.success && result.counts) { + normalizedCounts = Object.fromEntries( + Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) + ) + } + } catch (error) { + console.error('Failed to load contact post counts:', error) + } + + let resolved = 0 + let cursor = 0 + const applyBatch = () => { + if (runToken !== contactsCountHydrationTokenRef.current) return + + const batch = targets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) + if (batch.length === 0) { + setContactsCountProgress({ + resolved: totalTargets, + total: totalTargets, + running: false + }) + contactsCountBatchTimerRef.current = null + return + } + + const batchSet = new Set(batch) + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!batchSet.has(contact.username)) return contact + const nextCount = normalizePostCount(normalizedCounts[contact.username]) + if (contact.postCountStatus === 'ready' && contact.postCount === nextCount) return contact + changed = true + return { + ...contact, + postCount: nextCount, + postCountStatus: 'ready' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev + }) + + resolved += batch.length + cursor += batch.length + setContactsCountProgress({ + resolved, + total: totalTargets, + running: resolved < totalTargets + }) + + if (cursor < totalTargets) { + contactsCountBatchTimerRef.current = window.setTimeout(applyBatch, CONTACT_COUNT_SORT_DEBOUNCE_MS) + } else { + contactsCountBatchTimerRef.current = null + } + } + + applyBatch() + }, [normalizePostCount, sortContactsForRanking, stopContactsCountHydration]) + + // Load Contacts(先按最近会话显示联系人,再异步统计朋友圈条数并增量排序) const loadContacts = useCallback(async () => { const requestToken = ++contactsLoadTokenRef.current + stopContactsCountHydration(true) setContactsLoading(true) try { - const contactsResult = await window.electronAPI.chat.getContacts() + const [contactsResult, sessionsResult] = await Promise.all([ + window.electronAPI.chat.getContacts(), + window.electronAPI.chat.getSessions() + ]) const contactMap = new Map() + const sessionTimestampMap = new Map() + + if (sessionsResult.success && Array.isArray(sessionsResult.sessions)) { + for (const session of sessionsResult.sessions) { + const username = String(session?.username || '').trim() + if (!username) continue + const ts = Math.max( + Number(session?.sortTimestamp || 0), + Number(session?.lastTimestamp || 0) + ) + const prevTs = Number(sessionTimestampMap.get(username) || 0) + if (ts > prevTs) { + sessionTimestampMap.set(username, ts) + } + } + } if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { @@ -438,16 +615,19 @@ export default function SnsPage() { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, - type: c.type === 'former_friend' ? 'former_friend' : 'friend' + type: c.type === 'former_friend' ? 'former_friend' : 'friend', + lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), + postCount: undefined, + postCountStatus: 'idle' }) } } } - let contactsList = Array.from(contactMap.values()) - + let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) + void hydrateContactPostCounts(contactsList.map(contact => contact.username)) const allUsernames = contactsList.map(c => c.username) @@ -455,7 +635,7 @@ export default function SnsPage() { if (allUsernames.length > 0) { const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(allUsernames) if (enriched.success && enriched.contacts) { - contactsList = contactsList.map(contact => { + contactsList = contactsList.map((contact) => { const extra = enriched.contacts?.[contact.username] if (!extra) return contact return { @@ -465,18 +645,31 @@ export default function SnsPage() { } }) if (requestToken !== contactsLoadTokenRef.current) return - setContacts(contactsList) + setContacts((prev) => { + const prevMap = new Map(prev.map((contact) => [contact.username, contact])) + const merged = contactsList.map((contact) => { + const previous = prevMap.get(contact.username) + return { + ...contact, + lastSessionTimestamp: previous?.lastSessionTimestamp ?? contact.lastSessionTimestamp, + postCount: previous?.postCount, + postCountStatus: previous?.postCountStatus ?? contact.postCountStatus + } + }) + return sortContactsForRanking(merged) + }) } } } catch (error) { if (requestToken !== contactsLoadTokenRef.current) return console.error('Failed to load contacts:', error) + stopContactsCountHydration(true) } finally { if (requestToken === contactsLoadTokenRef.current) { setContactsLoading(false) } } - }, []) + }, [hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 @@ -631,10 +824,22 @@ export default function SnsPage() { loadOverviewStats() }, [hydrateSnsPageCache, loadContacts, loadOverviewStats]) + useEffect(() => { + return () => { + contactsCountHydrationTokenRef.current += 1 + if (contactsCountBatchTimerRef.current) { + window.clearTimeout(contactsCountBatchTimerRef.current) + contactsCountBatchTimerRef.current = null + } + } + }, []) + useEffect(() => { const handleChange = () => { cacheScopeKeyRef.current = '' // wxid changed, reset everything + stopContactsCountHydration(true) + setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); void hydrateSnsPageCache() @@ -644,7 +849,7 @@ export default function SnsPage() { } window.addEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts]) + }, [hydrateSnsPageCache, loadContacts, loadOverviewStats, loadPosts, stopContactsCountHydration]) useEffect(() => { const timer = setTimeout(() => { @@ -858,6 +1063,7 @@ export default function SnsPage() { contactSearch={contactSearch} setContactSearch={setContactSearch} loading={contactsLoading} + contactsCountProgress={contactsCountProgress} /> {/* Dialogs and Overlays */} From d07e4c8ecda38fde22de00a8e87e8c58ed7d11d6 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:02:38 +0800 Subject: [PATCH 19/31] chore(sns): remove my-posts segment from overview stats line --- src/pages/SnsPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 4cb4a2b..8f6e733 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -353,8 +353,7 @@ export default function SnsPage() { if (overviewStatsStatus === 'loading') { return '统计中...' } - const myPostsLabel = overviewStats.myPosts === null ? '--' : String(overviewStats.myPosts) - return `共 ${overviewStats.totalPosts} 条 | 我的朋友圈 ${myPostsLabel} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友` + return `共 ${overviewStats.totalPosts} 条 | ${formatDateOnly(overviewStats.earliestTime)} ~ ${formatDateOnly(overviewStats.latestTime)} | ${overviewStats.totalFriends} 位好友` } const loadPosts = useCallback(async (options: { reset?: boolean, direction?: 'older' | 'newer' } = {}) => { From 4e0038c81397a4f8ec8c65882df530b56cc28ca4 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:07:13 +0800 Subject: [PATCH 20/31] feat(export): include sns count loading progress in load detail --- src/pages/ExportPage.tsx | 182 ++++++++++++++++++++++++++++++++++----- 1 file changed, 162 insertions(+), 20 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index c32f5e4..98ca8a2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -171,6 +171,8 @@ const SESSION_MEDIA_METRIC_BATCH_SIZE = 12 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE = 48 const SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS = 120 const SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS = 1200 +const SNS_USER_POST_COUNT_BATCH_SIZE = 12 +const SNS_USER_POST_COUNT_BATCH_INTERVAL_MS = 120 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -725,6 +727,7 @@ interface SessionLoadStageState { interface SessionLoadTraceState { messageCount: SessionLoadStageState mediaMetrics: SessionLoadStageState + snsPostCounts: SessionLoadStageState } interface SessionLoadStageSummary { @@ -959,7 +962,8 @@ const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'p const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ messageCount: createDefaultSessionLoadStage(), - mediaMetrics: createDefaultSessionLoadStage() + mediaMetrics: createDefaultSessionLoadStage(), + snsPostCounts: createDefaultSessionLoadStage() }) const WriteLayoutSelector = memo(function WriteLayoutSelector({ @@ -1419,6 +1423,8 @@ function ExportPage() { const sessionSnsTimelinePostsRef = useRef([]) const sessionSnsTimelineLoadingRef = useRef(false) const sessionSnsTimelineRequestTokenRef = useRef(0) + const snsUserPostCountsHydrationTokenRef = useRef(0) + const snsUserPostCountsBatchTimerRef = useRef(null) const sessionPreciseRefreshAtRef = useRef>({}) const sessionLoadProgressSnapshotRef = useRef>({}) const sessionMediaMetricQueueRef = useRef([]) @@ -1955,28 +1961,86 @@ function ExportPage() { if (snsUserPostCountsStatus === 'loading') return if (!options?.force && snsUserPostCountsStatus === 'ready') return + const targetSessionIds = sessionsRef.current + .filter((session) => session.hasSession && isSingleContactSession(session.username)) + .map((session) => session.username) + + snsUserPostCountsHydrationTokenRef.current += 1 + const runToken = snsUserPostCountsHydrationTokenRef.current + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + + if (targetSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'pending', { force: true }) + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'loading') setSnsUserPostCountsStatus('loading') + + let normalizedCounts: Record = {} try { const result = await window.electronAPI.sns.getUserPostCounts() - if (result.success && result.counts) { - const normalized: Record = {} - for (const [rawUsername, rawCount] of Object.entries(result.counts)) { - const username = String(rawUsername || '').trim() - if (!username) continue - const value = Number(rawCount) - normalized[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 - } - setSnsUserPostCounts(normalized) - setSnsUserPostCountsStatus('ready') + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + if (!result.success || !result.counts) { + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + error: result.error || '朋友圈条数统计失败' + }) + setSnsUserPostCountsStatus('error') return } - setSnsUserPostCountsStatus('error') + for (const [rawUsername, rawCount] of Object.entries(result.counts)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const value = Number(rawCount) + normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + } } catch (error) { console.error('加载朋友圈用户条数失败:', error) + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + error: String(error) + }) setSnsUserPostCountsStatus('error') + return } - }, [snsUserPostCountsStatus]) + + let cursor = 0 + const applyBatch = () => { + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + const batchSessionIds = targetSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) + if (batchSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + return + } + + const batchCounts: Record = {} + for (const sessionId of batchSessionIds) { + const nextCount = normalizedCounts[sessionId] + batchCounts[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + } + + setSnsUserPostCounts(prev => ({ ...prev, ...batchCounts })) + patchSessionLoadTraceStage(batchSessionIds, 'snsPostCounts', 'done') + + cursor += batchSessionIds.length + if (cursor < targetSessionIds.length) { + snsUserPostCountsBatchTimerRef.current = window.setTimeout(applyBatch, SNS_USER_POST_COUNT_BATCH_INTERVAL_MS) + } else { + setSnsUserPostCountsStatus('ready') + snsUserPostCountsBatchTimerRef.current = null + } + } + + applyBatch() + }, [patchSessionLoadTraceStage, snsUserPostCountsStatus]) const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { const reset = Boolean(options?.reset) @@ -2080,7 +2144,7 @@ function ExportPage() { } void loadSessionSnsTimelinePosts(target, { reset: true }) - void loadSnsUserPostCounts({ force: true }) + void loadSnsUserPostCounts() }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, @@ -2568,6 +2632,13 @@ function ExportPage() { setSessionLoadTraceMap({}) setSessionLoadProgressPulseMap({}) sessionLoadProgressSnapshotRef.current = {} + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + setSnsUserPostCounts({}) + setSnsUserPostCountsStatus('idle') setIsLoadingSessionCounts(false) setIsSessionCountStageReady(false) @@ -2895,8 +2966,14 @@ function ExportPage() { // 导出页隐藏时停止后台联系人补齐请求,避免与通讯录页面查询抢占。 sessionLoadTokenRef.current = Date.now() sessionCountRequestIdRef.current += 1 + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } setIsSessionEnriching(false) setIsLoadingSessionCounts(false) + setSnsUserPostCountsStatus('idle') }, [isExportRoute]) useEffect(() => { @@ -4087,18 +4164,31 @@ function ExportPage() { } }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => { + return { + total: 0, + loaded: 0, + statusLabel: '不适用' + } + }, []) + const sessionLoadDetailRows = useMemo(() => { const tabOrder: ConversationTab[] = ['private', 'group', 'official', 'former_friend'] return tabOrder.map((tab) => { const sessionIds = loadDetailTargetsByTab[tab] || [] + const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId)) + const snsPostCounts = tab === 'private' || tab === 'former_friend' + ? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts') + : createNotApplicableLoadSummary() return { tab, label: conversationTabLabels[tab], messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), - mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics') + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'), + snsPostCounts } }) - }, [loadDetailTargetsByTab, summarizeLoadTraceForTab]) + }, [createNotApplicableLoadSummary, loadDetailTargetsByTab, summarizeLoadTraceForTab]) const formatLoadDetailPulseTime = useCallback((value?: number): string => { if (!value || !Number.isFinite(value)) return '--' @@ -4115,7 +4205,7 @@ function ExportPage() { const nextSnapshot: Record = {} const resetKeys: string[] = [] const updates: Array<{ key: string; at: number; delta: number }> = [] - const stageKeys: Array = ['messageCount', 'mediaMetrics'] + const stageKeys: Array = ['messageCount', 'mediaMetrics', 'snsPostCounts'] for (const row of sessionLoadDetailRows) { for (const stageKey of stageKeys) { @@ -4255,6 +4345,11 @@ function ExportPage() { useEffect(() => { return () => { + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } if (sessionMediaMetricBackgroundFeedTimerRef.current) { window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) sessionMediaMetricBackgroundFeedTimerRef.current = null @@ -4607,6 +4702,15 @@ function ExportPage() { snsUserPostCountsStatus ]) + useEffect(() => { + if (!isExportRoute || !isSessionCountStageReady) return + if (snsUserPostCountsStatus !== 'idle') return + const timer = window.setTimeout(() => { + void loadSnsUserPostCounts() + }, 260) + return () => window.clearTimeout(timer) + }, [isExportRoute, isSessionCountStageReady, loadSnsUserPostCounts, snsUserPostCountsStatus]) + useEffect(() => { if (!sessionSnsTimelineTarget) return if (snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle') { @@ -4654,7 +4758,7 @@ function ExportPage() { detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) if (isSingleContactSession(sessionId)) { - void loadSnsUserPostCounts({ force: true }) + void loadSnsUserPostCounts() } void loadSessionDetail(sessionId) }, [loadSessionDetail, loadSnsUserPostCounts]) @@ -4672,6 +4776,9 @@ function ExportPage() { useEffect(() => { if (!showSessionLoadDetailModal) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setShowSessionLoadDetailModal(false) @@ -4679,7 +4786,7 @@ function ExportPage() { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [showSessionLoadDetailModal]) + }, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus]) useEffect(() => { if (!sessionSnsTimelineTarget) return @@ -4811,7 +4918,8 @@ function ExportPage() { for (const row of sessionLoadDetailRows) { const candidateTimes = [ row.messageCount.finishedAt || row.messageCount.startedAt || 0, - row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0 + row.mediaMetrics.finishedAt || row.mediaMetrics.startedAt || 0, + row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0 ] for (const candidate of candidateTimes) { if (candidate > latest) { @@ -5432,6 +5540,40 @@ function ExportPage() { })}
+ +
+
朋友圈条数统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows.map((row) => { + const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] + const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') + return ( +
+ {row.label} + + {row.snsPostCounts.statusLabel} + {isLoading && ( + + )} + {isLoading && pulse && pulse.delta > 0 && ( + + {formatLoadDetailPulseTime(pulse.at)} +{pulse.delta}条 + + )} + + {formatLoadDetailTime(row.snsPostCounts.startedAt)} + {formatLoadDetailTime(row.snsPostCounts.finishedAt)} +
+ ) + })} +
+
From 7ead55d80196b96ef325f86eabbf2508f9c1e33b Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:21:37 +0800 Subject: [PATCH 21/31] fix(export,sns): share sns user count cache across pages --- src/pages/ExportPage.tsx | 13 +++++- src/pages/SnsPage.tsx | 88 ++++++++++++++++++++++++++++++---------- src/services/config.ts | 58 ++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 23 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 98ca8a2..539ee92 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2000,6 +2000,17 @@ function ExportPage() { const value = Number(rawCount) normalizedCounts[username] = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 } + + void (async () => { + try { + const scopeKey = exportCacheScopeReadyRef.current + ? exportCacheScopeRef.current + : await ensureExportCacheScope() + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('写入导出页朋友圈条数缓存失败:', cacheError) + } + })() } catch (error) { console.error('加载朋友圈用户条数失败:', error) if (runToken !== snsUserPostCountsHydrationTokenRef.current) return @@ -2040,7 +2051,7 @@ function ExportPage() { } applyBatch() - }, [patchSessionLoadTraceStage, snsUserPostCountsStatus]) + }, [ensureExportCacheScope, patchSessionLoadTraceStage, snsUserPostCountsStatus]) const loadSessionSnsTimelinePosts = useCallback(async (target: SessionSnsTimelineTarget, options?: { reset?: boolean }) => { const reset = Boolean(options?.reset) diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index 8f6e733..b51ce85 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -113,12 +113,14 @@ export default function SnsPage() { const [hasNewer, setHasNewer] = useState(false) const [loadingNewer, setLoadingNewer] = useState(false) const postsRef = useRef([]) + const contactsRef = useRef([]) const overviewStatsRef = useRef(overviewStats) const overviewStatsStatusRef = useRef(overviewStatsStatus) const selectedUsernamesRef = useRef(selectedUsernames) const searchKeywordRef = useRef(searchKeyword) const jumpTargetDateRef = useRef(jumpTargetDate) const cacheScopeKeyRef = useRef('') + const snsUserPostCountsCacheScopeKeyRef = useRef('') const scrollAdjustmentRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null) const contactsLoadTokenRef = useRef(0) const contactsCountHydrationTokenRef = useRef(0) @@ -132,6 +134,9 @@ export default function SnsPage() { useEffect(() => { postsRef.current = posts }, [posts]) + useEffect(() => { + contactsRef.current = contacts + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -222,6 +227,21 @@ export default function SnsPage() { return scopeKey }, []) + const ensureSnsUserPostCountsCacheScopeKey = useCallback(async () => { + if (snsUserPostCountsCacheScopeKeyRef.current) return snsUserPostCountsCacheScopeKeyRef.current + const [wxidRaw, dbPathRaw] = await Promise.all([ + configService.getMyWxid(), + configService.getDbPath() + ]) + const wxid = String(wxidRaw || '').trim() + const dbPath = String(dbPathRaw || '').trim() + const scopeKey = (dbPath || wxid) + ? `${dbPath}::${wxid}` + : 'default' + snsUserPostCountsCacheScopeKeyRef.current = scopeKey + return scopeKey + }, []) + const persistSnsPageCache = useCallback(async (patch?: { posts?: SnsPost[]; overviewStats?: SnsOverviewStats }) => { if (!isDefaultViewNow()) return try { @@ -484,36 +504,47 @@ export default function SnsPage() { } }, []) - const hydrateContactPostCounts = useCallback(async (usernames: string[]) => { + const hydrateContactPostCounts = useCallback(async (usernames: string[], options?: { force?: boolean }) => { + const force = options?.force === true const targets = usernames .map((username) => String(username || '').trim()) .filter(Boolean) stopContactsCountHydration(true) if (targets.length === 0) return + const readySet = new Set( + contactsRef.current + .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') + .map((contact) => contact.username) + ) + const pendingTargets = force ? targets : targets.filter((username) => !readySet.has(username)) const runToken = ++contactsCountHydrationTokenRef.current const totalTargets = targets.length - const targetSet = new Set(targets) + const targetSet = new Set(pendingTargets) - setContacts((prev) => { - let changed = false - const next = prev.map((contact) => { - if (!targetSet.has(contact.username)) return contact - if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact - changed = true - return { - ...contact, - postCount: undefined, - postCountStatus: 'loading' as ContactPostCountStatus - } + if (pendingTargets.length > 0) { + setContacts((prev) => { + let changed = false + const next = prev.map((contact) => { + if (!targetSet.has(contact.username)) return contact + if (contact.postCountStatus === 'loading' && typeof contact.postCount !== 'number') return contact + changed = true + return { + ...contact, + postCount: force ? undefined : contact.postCount, + postCountStatus: 'loading' as ContactPostCountStatus + } + }) + return changed ? sortContactsForRanking(next) : prev }) - return changed ? sortContactsForRanking(next) : prev - }) + } + const preResolved = Math.max(0, totalTargets - pendingTargets.length) setContactsCountProgress({ - resolved: 0, + resolved: preResolved, total: totalTargets, - running: true + running: pendingTargets.length > 0 }) + if (pendingTargets.length === 0) return let normalizedCounts: Record = {} try { @@ -523,17 +554,25 @@ export default function SnsPage() { normalizedCounts = Object.fromEntries( Object.entries(result.counts).map(([username, value]) => [username, normalizePostCount(value)]) ) + void (async () => { + try { + const scopeKey = await ensureSnsUserPostCountsCacheScopeKey() + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('Failed to persist SNS user post counts cache:', cacheError) + } + })() } } catch (error) { console.error('Failed to load contact post counts:', error) } - let resolved = 0 + let resolved = preResolved let cursor = 0 const applyBatch = () => { if (runToken !== contactsCountHydrationTokenRef.current) return - const batch = targets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) + const batch = pendingTargets.slice(cursor, cursor + CONTACT_COUNT_BATCH_SIZE) if (batch.length === 0) { setContactsCountProgress({ resolved: totalTargets, @@ -585,6 +624,9 @@ export default function SnsPage() { stopContactsCountHydration(true) setContactsLoading(true) try { + const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() + const cachedPostCountsItem = await configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey) + const cachedPostCounts = cachedPostCountsItem?.counts || {} const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() @@ -610,14 +652,16 @@ export default function SnsPage() { if (contactsResult.success && contactsResult.contacts) { for (const c of contactsResult.contacts) { if (c.type === 'friend' || c.type === 'former_friend') { + const cachedCount = cachedPostCounts[c.username] + const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) contactMap.set(c.username, { username: c.username, displayName: c.displayName, avatarUrl: c.avatarUrl, type: c.type === 'former_friend' ? 'former_friend' : 'friend', lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), - postCount: undefined, - postCountStatus: 'idle' + postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, + postCountStatus: hasCachedCount ? 'ready' : 'idle' }) } } @@ -668,7 +712,7 @@ export default function SnsPage() { setContactsLoading(false) } } - }, [hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) + }, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) const closeAuthorTimeline = useCallback(() => { authorTimelineRequestTokenRef.current += 1 diff --git a/src/services/config.ts b/src/services/config.ts index b5a25c8..b644ca0 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -41,6 +41,7 @@ export const CONFIG_KEYS = { EXPORT_SESSION_MESSAGE_COUNT_CACHE_MAP: 'exportSessionMessageCountCacheMap', EXPORT_SESSION_CONTENT_METRIC_CACHE_MAP: 'exportSessionContentMetricCacheMap', EXPORT_SNS_STATS_CACHE_MAP: 'exportSnsStatsCacheMap', + EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP: 'exportSnsUserPostCountsCacheMap', SNS_PAGE_CACHE_MAP: 'snsPageCacheMap', CONTACTS_LOAD_TIMEOUT_MS: 'contactsLoadTimeoutMs', CONTACTS_LIST_CACHE_MAP: 'contactsListCacheMap', @@ -533,6 +534,11 @@ export interface ExportSnsStatsCacheItem { totalFriends: number } +export interface ExportSnsUserPostCountsCacheItem { + updatedAt: number + counts: Record +} + export interface SnsPageOverviewCache { totalPosts: number totalFriends: number @@ -740,6 +746,58 @@ export async function setExportSnsStatsCache( await config.set(CONFIG_KEYS.EXPORT_SNS_STATS_CACHE_MAP, map) } +export async function getExportSnsUserPostCountsCache(scopeKey: string): Promise { + if (!scopeKey) return null + const value = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP) + if (!value || typeof value !== 'object') return null + const rawMap = value as Record + const rawItem = rawMap[scopeKey] + if (!rawItem || typeof rawItem !== 'object') return null + + const raw = rawItem as Record + const rawCounts = raw.counts + if (!rawCounts || typeof rawCounts !== 'object') return null + + const counts: Record = {} + for (const [rawUsername, rawCount] of Object.entries(rawCounts as Record)) { + const username = String(rawUsername || '').trim() + if (!username) continue + const valueNum = Number(rawCount) + counts[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0 + } + + const updatedAt = typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) + ? raw.updatedAt + : 0 + return { updatedAt, counts } +} + +export async function setExportSnsUserPostCountsCache( + scopeKey: string, + counts: Record +): Promise { + if (!scopeKey) return + const current = await config.get(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP) + const map = current && typeof current === 'object' + ? { ...(current as Record) } + : {} + + const normalized: Record = {} + for (const [rawUsername, rawCount] of Object.entries(counts || {})) { + const username = String(rawUsername || '').trim() + if (!username) continue + const valueNum = Number(rawCount) + normalized[username] = Number.isFinite(valueNum) ? Math.max(0, Math.floor(valueNum)) : 0 + } + + map[scopeKey] = { + updatedAt: Date.now(), + counts: normalized + } + + await config.set(CONFIG_KEYS.EXPORT_SNS_USER_POST_COUNTS_CACHE_MAP, map) +} + export async function getSnsPageCache(scopeKey: string): Promise { if (!scopeKey) return null const value = await config.get(CONFIG_KEYS.SNS_PAGE_CACHE_MAP) From 2140a220e255a71f292f5eac1150287cdc75bc7a Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:28:28 +0800 Subject: [PATCH 22/31] fix(electron): ensure app quits after main window close in dev --- electron/main.ts | 21 ++++++++++++++++++++- electron/windows/notificationWindow.ts | 22 ++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/electron/main.ts b/electron/main.ts index 21f63a4..f4ccb98 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -24,7 +24,7 @@ import { windowsHelloService } from './services/windowsHelloService' import { exportCardDiagnosticsService } from './services/exportCardDiagnosticsService' import { cloudControlService } from './services/cloudControlService' -import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' +import { destroyNotificationWindow, registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { httpService } from './services/httpService' @@ -92,6 +92,7 @@ const keyService = new KeyService() let mainWindowReady = false let shouldShowMain = true +let isAppQuitting = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -332,6 +333,21 @@ function createWindow(options: { autoShow?: boolean } = {}) { callback(false) }) + win.on('closed', () => { + if (mainWindow !== win) return + + mainWindow = null + mainWindowReady = false + + if (process.platform !== 'darwin' && !isAppQuitting) { + // 隐藏通知窗也是 BrowserWindow,必须销毁,否则会阻止应用退出。 + destroyNotificationWindow() + if (BrowserWindow.getAllWindows().length === 0) { + app.quit() + } + } + }) + return win } @@ -2427,6 +2443,9 @@ app.whenReady().then(async () => { }) app.on('before-quit', async () => { + isAppQuitting = true + // 通知窗使用 hide 而非 close,退出时主动销毁,避免残留窗口阻塞进程退出。 + destroyNotificationWindow() // 停止 HTTP 服务器,释放 TCP 端口占用,避免进程无法退出 try { await httpService.stop() } catch {} // 终止 wcdb Worker 线程,避免线程阻止进程退出 diff --git a/electron/windows/notificationWindow.ts b/electron/windows/notificationWindow.ts index ec58eac..1642924 100644 --- a/electron/windows/notificationWindow.ts +++ b/electron/windows/notificationWindow.ts @@ -5,6 +5,28 @@ import { ConfigService } from '../services/config' let notificationWindow: BrowserWindow | null = null let closeTimer: NodeJS.Timeout | null = null +export function destroyNotificationWindow() { + if (closeTimer) { + clearTimeout(closeTimer) + closeTimer = null + } + lastNotificationData = null + + if (!notificationWindow || notificationWindow.isDestroyed()) { + notificationWindow = null + return + } + + const win = notificationWindow + notificationWindow = null + + try { + win.destroy() + } catch (error) { + console.warn('[NotificationWindow] Failed to destroy window:', error) + } +} + export function createNotificationWindow() { if (notificationWindow && !notificationWindow.isDestroyed()) { return notificationWindow From c625756ab4e848c23b154bd5e8d875e88604e897 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:35:42 +0800 Subject: [PATCH 23/31] fix(export,sns): preserve sns load state across route switches --- src/pages/ExportPage.tsx | 47 +++++++++++++++++++++++++++------- src/pages/SnsPage.tsx | 54 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 539ee92..5e89b9b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -1977,8 +1977,40 @@ function ExportPage() { return } - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'pending', { force: true }) - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'loading') + const scopeKey = exportCacheScopeReadyRef.current + ? exportCacheScopeRef.current + : await ensureExportCacheScope() + const targetSet = new Set(targetSessionIds) + let cachedCounts: Record = {} + try { + const cached = await configService.getExportSnsUserPostCountsCache(scopeKey) + cachedCounts = cached?.counts || {} + } catch (cacheError) { + console.error('读取导出页朋友圈条数缓存失败:', cacheError) + } + + const cachedTargetCounts = Object.entries(cachedCounts).reduce>((acc, [sessionId, countRaw]) => { + if (!targetSet.has(sessionId)) return acc + const nextCount = Number(countRaw) + acc[sessionId] = Number.isFinite(nextCount) ? Math.max(0, Math.floor(nextCount)) : 0 + return acc + }, {}) + const cachedReadySessionIds = Object.keys(cachedTargetCounts) + if (cachedReadySessionIds.length > 0) { + setSnsUserPostCounts(prev => ({ ...prev, ...cachedTargetCounts })) + patchSessionLoadTraceStage(cachedReadySessionIds, 'snsPostCounts', 'done') + } + + const pendingSessionIds = options?.force + ? targetSessionIds + : targetSessionIds.filter((sessionId) => !(sessionId in cachedTargetCounts)) + if (pendingSessionIds.length === 0) { + setSnsUserPostCountsStatus('ready') + return + } + + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'pending', { force: true }) + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'loading') setSnsUserPostCountsStatus('loading') let normalizedCounts: Record = {} @@ -1987,7 +2019,7 @@ function ExportPage() { if (runToken !== snsUserPostCountsHydrationTokenRef.current) return if (!result.success || !result.counts) { - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { error: result.error || '朋友圈条数统计失败' }) setSnsUserPostCountsStatus('error') @@ -2003,9 +2035,6 @@ function ExportPage() { void (async () => { try { - const scopeKey = exportCacheScopeReadyRef.current - ? exportCacheScopeRef.current - : await ensureExportCacheScope() await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) } catch (cacheError) { console.error('写入导出页朋友圈条数缓存失败:', cacheError) @@ -2014,7 +2043,7 @@ function ExportPage() { } catch (error) { console.error('加载朋友圈用户条数失败:', error) if (runToken !== snsUserPostCountsHydrationTokenRef.current) return - patchSessionLoadTraceStage(targetSessionIds, 'snsPostCounts', 'failed', { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { error: String(error) }) setSnsUserPostCountsStatus('error') @@ -2025,7 +2054,7 @@ function ExportPage() { const applyBatch = () => { if (runToken !== snsUserPostCountsHydrationTokenRef.current) return - const batchSessionIds = targetSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) + const batchSessionIds = pendingSessionIds.slice(cursor, cursor + SNS_USER_POST_COUNT_BATCH_SIZE) if (batchSessionIds.length === 0) { setSnsUserPostCountsStatus('ready') snsUserPostCountsBatchTimerRef.current = null @@ -2984,7 +3013,7 @@ function ExportPage() { } setIsSessionEnriching(false) setIsLoadingSessionCounts(false) - setSnsUserPostCountsStatus('idle') + setSnsUserPostCountsStatus(prev => (prev === 'loading' ? 'idle' : prev)) }, [isExportRoute]) useEffect(() => { diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index b51ce85..f088e76 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -504,7 +504,10 @@ export default function SnsPage() { } }, []) - const hydrateContactPostCounts = useCallback(async (usernames: string[], options?: { force?: boolean }) => { + const hydrateContactPostCounts = useCallback(async ( + usernames: string[], + options?: { force?: boolean; readyUsernames?: Set } + ) => { const force = options?.force === true const targets = usernames .map((username) => String(username || '').trim()) @@ -512,7 +515,7 @@ export default function SnsPage() { stopContactsCountHydration(true) if (targets.length === 0) return - const readySet = new Set( + const readySet = options?.readyUsernames || new Set( contactsRef.current .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') .map((contact) => contact.username) @@ -625,8 +628,42 @@ export default function SnsPage() { setContactsLoading(true) try { const snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() - const cachedPostCountsItem = await configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey) + const [cachedPostCountsItem, cachedContactsItem, cachedAvatarItem] = await Promise.all([ + configService.getExportSnsUserPostCountsCache(snsPostCountsScopeKey), + configService.getContactsListCache(snsPostCountsScopeKey), + configService.getContactsAvatarCache(snsPostCountsScopeKey) + ]) const cachedPostCounts = cachedPostCountsItem?.counts || {} + const cachedAvatarMap = cachedAvatarItem?.avatars || {} + const cachedContacts = (cachedContactsItem?.contacts || []) + .filter((contact) => contact.type === 'friend' || contact.type === 'former_friend') + .map((contact) => { + const cachedCount = cachedPostCounts[contact.username] + const hasCachedCount = typeof cachedCount === 'number' && Number.isFinite(cachedCount) + return { + username: contact.username, + displayName: contact.displayName || contact.username, + avatarUrl: cachedAvatarMap[contact.username]?.avatarUrl, + type: (contact.type === 'former_friend' ? 'former_friend' : 'friend') as 'friend' | 'former_friend', + lastSessionTimestamp: 0, + postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, + postCountStatus: hasCachedCount ? 'ready' as ContactPostCountStatus : 'idle' as ContactPostCountStatus + } + }) + + if (requestToken !== contactsLoadTokenRef.current) return + if (cachedContacts.length > 0) { + const cachedContactsSorted = sortContactsForRanking(cachedContacts) + setContacts(cachedContactsSorted) + setContactsLoading(false) + const cachedReadyCount = cachedContactsSorted.filter(contact => contact.postCountStatus === 'ready').length + setContactsCountProgress({ + resolved: cachedReadyCount, + total: cachedContactsSorted.length, + running: cachedReadyCount < cachedContactsSorted.length + }) + } + const [contactsResult, sessionsResult] = await Promise.all([ window.electronAPI.chat.getContacts(), window.electronAPI.chat.getSessions() @@ -670,7 +707,15 @@ export default function SnsPage() { let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) - void hydrateContactPostCounts(contactsList.map(contact => contact.username)) + const readyUsernames = new Set( + contactsList + .filter((contact) => contact.postCountStatus === 'ready' && typeof contact.postCount === 'number') + .map((contact) => contact.username) + ) + void hydrateContactPostCounts( + contactsList.map(contact => contact.username), + { readyUsernames } + ) const allUsernames = contactsList.map(c => c.username) @@ -880,6 +925,7 @@ export default function SnsPage() { useEffect(() => { const handleChange = () => { cacheScopeKeyRef.current = '' + snsUserPostCountsCacheScopeKeyRef.current = '' // wxid changed, reset everything stopContactsCountHydration(true) setContacts([]) From 2eff82891e32303f236f870dbeed3f62be32d684 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:43:11 +0800 Subject: [PATCH 24/31] feat(export): add clickable sns count column in session list --- src/pages/ExportPage.scss | 32 ++++++++++++++++++ src/pages/ExportPage.tsx | 69 ++++++++++++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 80cc66e..c22a09c 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1559,6 +1559,38 @@ color: var(--text-tertiary); } + .row-sns-metric-btn { + border: none; + background: transparent; + margin: 0; + padding: 0; + min-height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + line-height: 1.2; + color: var(--primary); + font-variant-numeric: tabular-nums; + cursor: pointer; + + &:hover { + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 48%, transparent); + outline-offset: 2px; + border-radius: 4px; + } + + &.loading { + color: var(--text-tertiary); + } + } + .row-message-stats { width: 100%; display: flex; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 5e89b9b..259c7e0 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -2158,16 +2158,7 @@ function ExportPage() { setSessionSnsTimelineStatsLoading(false) }, []) - const openSessionSnsTimeline = useCallback(() => { - const normalizedSessionId = String(sessionDetail?.wxid || '').trim() - if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return - - const target: SessionSnsTimelineTarget = { - username: normalizedSessionId, - displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, - avatarUrl: sessionDetail.avatarUrl - } - + const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2175,7 +2166,7 @@ function ExportPage() { setSessionSnsTimelineLoading(false) if (snsUserPostCountsStatus === 'ready') { - const count = Number(snsUserPostCounts[normalizedSessionId] || 0) + const count = Number(snsUserPostCounts[target.username] || 0) setSessionSnsTimelineTotalPosts(Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0) setSessionSnsTimelineStatsLoading(false) } else { @@ -2188,11 +2179,33 @@ function ExportPage() { }, [ loadSessionSnsTimelinePosts, loadSnsUserPostCounts, - sessionDetail, snsUserPostCounts, snsUserPostCountsStatus ]) + const openSessionSnsTimeline = useCallback(() => { + const normalizedSessionId = String(sessionDetail?.wxid || '').trim() + if (!isSingleContactSession(normalizedSessionId) || !sessionDetail) return + + const target: SessionSnsTimelineTarget = { + username: normalizedSessionId, + displayName: sessionDetail.displayName || sessionDetail.remark || sessionDetail.nickName || normalizedSessionId, + avatarUrl: sessionDetail.avatarUrl + } + + openSessionSnsTimelineByTarget(target) + }, [openSessionSnsTimelineByTarget, sessionDetail]) + + const openContactSnsTimeline = useCallback((contact: ContactInfo) => { + const normalizedSessionId = String(contact?.username || '').trim() + if (!isSingleContactSession(normalizedSessionId)) return + openSessionSnsTimelineByTarget({ + username: normalizedSessionId, + displayName: contact.displayName || contact.remark || contact.nickname || normalizedSessionId, + avatarUrl: contact.avatarUrl + }) + }, [openSessionSnsTimelineByTarget]) + const loadMoreSessionSnsTimeline = useCallback(() => { if (!sessionSnsTimelineTarget || sessionSnsTimelineLoading || sessionSnsTimelineLoadingMore || !sessionSnsTimelineHasMore) return void loadSessionSnsTimelinePosts(sessionSnsTimelineTarget, { reset: false }) @@ -4058,6 +4071,9 @@ function ExportPage() { if (activeTab === 'former_friend') return '曾经的好友' return '公众号' }, [activeTab]) + const shouldShowSnsColumn = useMemo(() => ( + activeTab === 'private' || activeTab === 'former_friend' + ), [activeTab]) const sessionRowByUsername = useMemo(() => { const map = new Map() @@ -5004,6 +5020,10 @@ function ExportPage() { const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) const imageMetric = metricToDisplay(mediaMetric?.imageMessages) const videoMetric = metricToDisplay(mediaMetric?.videoMessages) + const isSnsCountLoading = snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) + const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 + const supportsSnsTimeline = isSingleContactSession(contact.username) const openChatLabel = contact.type === 'friend' ? '打开私聊' : contact.type === 'group' @@ -5086,6 +5106,24 @@ function ExportPage() { : videoMetric.text}
+ {shouldShowSnsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )}
- @@ -5635,7 +5641,9 @@ function ExportPage() { 开始时间 完成时间
- {sessionLoadDetailRows.map((row) => { + {sessionLoadDetailRows + .filter((row) => row.tab === 'private') + .map((row) => { const pulse = sessionLoadProgressPulseMap[`snsPostCounts:${row.tab}`] const isLoading = row.snsPostCounts.statusLabel.startsWith('加载中') return ( From edaef537126b57ab8218f22425cecfb5a51a6014 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:52:23 +0800 Subject: [PATCH 26/31] feat(export): add sns detail sync tip between header and list --- src/pages/ExportPage.scss | 15 +++++++++++++++ src/pages/ExportPage.tsx | 4 ++++ 2 files changed, 19 insertions(+) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index c22a09c..93a49e5 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2298,6 +2298,16 @@ } } + .sns-dialog-tip { + padding: 10px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + background: color-mix(in srgb, var(--bg-primary) 78%, var(--bg-secondary)); + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + word-break: break-word; + } + .sns-dialog-body { flex: 1; min-height: 0; @@ -3244,6 +3254,11 @@ padding: 12px; } + .sns-dialog-tip { + padding: 10px 12px; + line-height: 1.55; + } + .sns-dialog-body { padding: 10px 10px 12px; } diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 0b45ed0..6fe00e2 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6016,6 +6016,10 @@ function ExportPage() {
+
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+
{sessionSnsTimelinePosts.length > 0 && (
From 3e004867bee9d59a62722ad728a3d42b0fdd2eb2 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 19:55:17 +0800 Subject: [PATCH 27/31] fix(export): show sns counts per-session as soon as loaded --- src/pages/ExportPage.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 6fe00e2..4db0498 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -5029,10 +5029,21 @@ function ExportPage() { const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) const imageMetric = metricToDisplay(mediaMetric?.imageMessages) const videoMetric = metricToDisplay(mediaMetric?.videoMessages) - const isSnsCountLoading = snsUserPostCountsStatus === 'loading' || snsUserPostCountsStatus === 'idle' + const supportsSnsTimeline = isSingleContactSession(contact.username) + const hasSnsCount = Object.prototype.hasOwnProperty.call(snsUserPostCounts, contact.username) + const snsStageStatus = sessionLoadTraceMap[contact.username]?.snsPostCounts?.status + const isSnsCountLoading = ( + supportsSnsTimeline && + !hasSnsCount && + ( + snsStageStatus === 'pending' || + snsStageStatus === 'loading' || + snsUserPostCountsStatus === 'loading' || + snsUserPostCountsStatus === 'idle' + ) + ) const snsRawCount = Number(snsUserPostCounts[contact.username] || 0) const snsCount = Number.isFinite(snsRawCount) ? Math.max(0, Math.floor(snsRawCount)) : 0 - const supportsSnsTimeline = isSingleContactSession(contact.username) const openChatLabel = contact.type === 'friend' ? '打开私聊' : contact.type === 'group' @@ -5126,7 +5137,9 @@ function ExportPage() { > {isSnsCountLoading ? - : `${snsCount.toLocaleString('zh-CN')} 条`} + : hasSnsCount + ? `${snsCount.toLocaleString('zh-CN')} 条` + : '--'} ) : ( -- @@ -5176,6 +5189,7 @@ function ExportPage() { selectedSessions, sessionDetail?.wxid, sessionContentMetrics, + sessionLoadTraceMap, sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, From ba2cdbf8cfaeb637429f39b683fa9c5a60a36382 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:02:14 +0800 Subject: [PATCH 28/31] feat(export): add sns like/comment ranking strip in detail header --- src/pages/ExportPage.scss | 112 ++++++++++++++++++++++++++++++++++++++ src/pages/ExportPage.tsx | 108 +++++++++++++++++++++++++++++++++++- 2 files changed, 217 insertions(+), 3 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 93a49e5..35a0472 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -2280,6 +2280,103 @@ color: var(--text-secondary); } + .sns-dialog-header-actions { + display: flex; + align-items: flex-start; + gap: 8px; + flex-shrink: 0; + } + + .sns-dialog-rank-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 6px; + } + + .sns-dialog-rank-btn { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + height: 28px; + padding: 0 10px; + font-size: 12px; + line-height: 1; + cursor: pointer; + white-space: nowrap; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + } + } + + .sns-dialog-rank-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 248px; + max-height: 220px; + overflow-y: auto; + border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); + border-radius: 10px; + background: var(--bg-primary-solid, #fff); + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); + padding: 8px; + z-index: 12; + } + + .sns-dialog-rank-empty { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; + text-align: center; + padding: 6px 0; + } + + .sns-dialog-rank-row { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 4px; + border-radius: 7px; + + &:hover { + background: var(--bg-hover); + } + } + + .sns-dialog-rank-index { + font-size: 12px; + color: var(--text-tertiary); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .sns-dialog-rank-name { + font-size: 12px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .sns-dialog-rank-count { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + .close-btn { border: none; background: transparent; @@ -3254,6 +3351,21 @@ padding: 12px; } + .sns-dialog-header-actions { + gap: 6px; + } + + .sns-dialog-rank-btn { + height: 26px; + padding: 0 8px; + font-size: 11px; + } + + .sns-dialog-rank-panel { + width: min(78vw, 232px); + max-height: 190px; + } + .sns-dialog-tip { padding: 10px 12px; line-height: 1.55; diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4db0498..4026c5a 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -47,6 +47,7 @@ type TaskStatus = 'queued' | 'running' | 'success' | 'error' type TaskScope = 'single' | 'multi' | 'content' | 'sns' type ContentType = 'text' | 'voice' | 'image' | 'video' | 'emoji' type ContentCardType = ContentType | 'sns' +type SnsRankMode = 'likes' | 'comments' type SessionLayout = 'shared' | 'per-session' @@ -1335,6 +1336,7 @@ function ExportPage() { const [sessionSnsTimelineHasMore, setSessionSnsTimelineHasMore] = useState(false) const [sessionSnsTimelineTotalPosts, setSessionSnsTimelineTotalPosts] = useState(null) const [sessionSnsTimelineStatsLoading, setSessionSnsTimelineStatsLoading] = useState(false) + const [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -2153,6 +2155,7 @@ function ExportPage() { const closeSessionSnsTimeline = useCallback(() => { sessionSnsTimelineRequestTokenRef.current += 1 sessionSnsTimelineLoadingRef.current = false + setSessionSnsRankMode(null) setSessionSnsTimelineTarget(null) setSessionSnsTimelinePosts([]) setSessionSnsTimelineLoading(false) @@ -2163,6 +2166,7 @@ function ExportPage() { }, []) const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + setSessionSnsRankMode(null) setSessionSnsTimelineTarget(target) setSessionSnsTimelinePosts([]) setSessionSnsTimelineHasMore(false) @@ -2243,6 +2247,62 @@ function ExportPage() { sessionSnsTimelineTotalPosts ]) + const sessionSnsLikeRankings = useMemo(() => { + const rankMap = new Map() + for (const post of sessionSnsTimelinePosts) { + const createTime = Number(post?.createTime) || 0 + const likes = Array.isArray(post?.likes) ? post.likes : [] + for (const likeNameRaw of likes) { + const name = String(likeNameRaw || '').trim() || '未知用户' + const current = rankMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + rankMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + return [...rankMap.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + }, [sessionSnsTimelinePosts]) + + const sessionSnsCommentRankings = useMemo(() => { + const rankMap = new Map() + for (const post of sessionSnsTimelinePosts) { + const createTime = Number(post?.createTime) || 0 + const comments = Array.isArray(post?.comments) ? post.comments : [] + for (const comment of comments) { + const name = String(comment?.nickname || '').trim() || '未知用户' + const current = rankMap.get(name) + if (current) { + current.count += 1 + if (createTime > current.latestTime) current.latestTime = createTime + continue + } + rankMap.set(name, { name, count: 1, latestTime: createTime }) + } + } + return [...rankMap.values()].sort((a, b) => { + if (b.count !== a.count) return b.count - a.count + if (b.latestTime !== a.latestTime) return b.latestTime - a.latestTime + return a.name.localeCompare(b.name, 'zh-CN') + }) + }, [sessionSnsTimelinePosts]) + + const toggleSessionSnsRankMode = useCallback((mode: SnsRankMode) => { + setSessionSnsRankMode((prev) => (prev === mode ? null : mode)) + }, []) + + const sessionSnsActiveRankings = useMemo(() => { + if (sessionSnsRankMode === 'likes') return sessionSnsLikeRankings + if (sessionSnsRankMode === 'comments') return sessionSnsCommentRankings + return [] + }, [sessionSnsCommentRankings, sessionSnsLikeRankings, sessionSnsRankMode]) + const mergeSessionContentMetrics = useCallback((input: Record) => { const entries = Object.entries(input) if (entries.length === 0) return @@ -6025,9 +6085,51 @@ function ExportPage() {
{renderSessionSnsTimelineStats()}
- +
+
+ + + {sessionSnsRankMode && ( +
+ {sessionSnsActiveRankings.length === 0 ? ( +
+ {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + sessionSnsActiveRankings.slice(0, 10).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {sessionSnsRankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
From 7cea8b4fb376399820cd5eba284c2b6a540e5bbf Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:10:47 +0800 Subject: [PATCH 29/31] fix(export): tune sns rank strip size and theme compatibility --- src/pages/ExportPage.scss | 99 +++++++++++++++++++++++++++++---------- src/pages/ExportPage.tsx | 45 +++++++++--------- 2 files changed, 98 insertions(+), 46 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 35a0472..4606e21 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1794,7 +1794,7 @@ } } - .row-action-cell { +.row-action-cell { display: flex; flex-direction: column; align-items: flex-end; @@ -1804,7 +1804,7 @@ .row-action-main { display: inline-flex; - align-items: center; + align-items: flex-start; gap: 6px; } @@ -1831,47 +1831,95 @@ } } - .row-export-btn { + .row-export-action-stack { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 2px; + min-width: 84px; + } + + .row-export-link { border: none; - border-radius: 8px; - padding: 7px 10px; - background: var(--primary); - color: #fff; + padding: 0; + margin: 0; + background: transparent; + color: var(--primary); font-size: 12px; cursor: pointer; - display: flex; - align-items: center; - gap: 5px; + line-height: 1.2; + font-weight: 600; + white-space: nowrap; &:hover:not(:disabled) { - background: var(--primary-hover); + color: var(--primary-hover); + text-decoration: underline; + text-underline-offset: 2px; } &:disabled { - opacity: 0.75; cursor: not-allowed; } - &.running { - background: color-mix(in srgb, var(--primary) 80%, #000); + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--primary) 30%, transparent); + outline-offset: 2px; + border-radius: 4px; } - &.paused { - background: rgba(250, 173, 20, 0.16); - color: #d48806; - border: 1px solid rgba(250, 173, 20, 0.38); + &.state-running { + cursor: progress; } - &.no-session { - background: var(--bg-secondary); + &.state-disabled { color: var(--text-tertiary); - border: 1px dashed var(--border-color); + text-decoration: none; } } + .row-export-meta { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 1px; + } + + .row-export-meta-label { + font-size: 10px; + line-height: 1.2; + color: var(--text-tertiary); + font-weight: 500; + } + .row-export-time { font-size: 11px; + line-height: 1.2; color: var(--text-tertiary); + font-variant-numeric: tabular-nums; + white-space: nowrap; + text-align: center; + } + + .row-export-link.state-running + .row-export-meta .row-export-time { + color: var(--primary); + font-weight: 600; + } + + .row-export-link.state-running:hover:not(:disabled), + .row-export-link.state-running:focus-visible { + color: var(--primary); + text-decoration: none; + } + + .row-export-link.state-disabled + .row-export-meta .row-export-meta-label, + .row-export-link.state-disabled + .row-export-meta .row-export-time { + color: var(--text-tertiary); + } + + .row-export-link.state-disabled:hover:not(:disabled), + .row-export-link.state-disabled:focus-visible { + color: var(--text-tertiary); + text-decoration: none; } } @@ -2323,11 +2371,11 @@ top: calc(100% + 8px); right: 0; width: 248px; - max-height: 220px; + max-height: calc((28px * 15) + 16px); overflow-y: auto; border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color)); border-radius: 10px; - background: var(--bg-primary-solid, #fff); + background: var(--bg-primary); box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18); padding: 8px; z-index: 12; @@ -3292,7 +3340,8 @@ font-size: 12px; } - .table-wrap .row-open-chat-link { + .table-wrap .row-open-chat-link, + .table-wrap .row-export-link { font-size: 11px; } @@ -3363,7 +3412,7 @@ .sns-dialog-rank-panel { width: min(78vw, 232px); - max-height: 190px; + max-height: calc((28px * 15) + 16px); } .sns-dialog-tip { diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 4026c5a..3891e01 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -444,7 +444,7 @@ const formatPathBrief = (value: string, maxLength = 52): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '' + if (!timestamp) return '未导出' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -5067,7 +5067,7 @@ function ExportPage() { const checked = canExport && selectedSessions.has(contact.username) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) - const recent = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '' + const recentExportTime = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '—' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -5214,26 +5214,29 @@ function ExportPage() { > 详情 - +
+ + {canExport && ( +
+ 最近导出 + {recentExportTime} +
+ )} +
- {recent && {recent}} From 05c551d7acd8199d19f57b6109b25a958e093c21 Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:24:55 +0800 Subject: [PATCH 30/31] fix(export): hide recent-export row when no history --- src/pages/ExportPage.scss | 21 +-------------------- src/pages/ExportPage.tsx | 13 +++++-------- 2 files changed, 6 insertions(+), 28 deletions(-) diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 4606e21..3410552 100644 --- a/src/pages/ExportPage.scss +++ b/src/pages/ExportPage.scss @@ -1877,20 +1877,6 @@ } } - .row-export-meta { - display: inline-flex; - flex-direction: column; - align-items: center; - gap: 1px; - } - - .row-export-meta-label { - font-size: 10px; - line-height: 1.2; - color: var(--text-tertiary); - font-weight: 500; - } - .row-export-time { font-size: 11px; line-height: 1.2; @@ -1900,7 +1886,7 @@ text-align: center; } - .row-export-link.state-running + .row-export-meta .row-export-time { + .row-export-link.state-running + .row-export-time { color: var(--primary); font-weight: 600; } @@ -1911,11 +1897,6 @@ text-decoration: none; } - .row-export-link.state-disabled + .row-export-meta .row-export-meta-label, - .row-export-link.state-disabled + .row-export-meta .row-export-time { - color: var(--text-tertiary); - } - .row-export-link.state-disabled:hover:not(:disabled), .row-export-link.state-disabled:focus-visible { color: var(--text-tertiary); diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 3891e01..f897e62 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -444,7 +444,7 @@ const formatPathBrief = (value: string, maxLength = 52): string => { } const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => { - if (!timestamp) return '未导出' + if (!timestamp) return '' const diff = Math.max(0, now - timestamp) const minute = 60 * 1000 const hour = 60 * minute @@ -5067,7 +5067,9 @@ function ExportPage() { const checked = canExport && selectedSessions.has(contact.username) const isRunning = canExport && runningSessionIds.has(contact.username) const isQueued = canExport && queuedSessionIds.has(contact.username) - const recentExportTime = canExport ? formatRecentExportTime(lastExportBySession[contact.username], nowTick) : '—' + const recentExportTimestamp = lastExportBySession[contact.username] + const hasRecentExport = canExport && Boolean(recentExportTimestamp) + const recentExportTime = hasRecentExport ? formatRecentExportTime(recentExportTimestamp, nowTick) : '' const countedMessages = normalizeMessageCount(sessionMessageCounts[contact.username]) const hintedMessages = normalizeMessageCount(matchedSession?.messageCountHint) const displayedMessageCount = countedMessages ?? hintedMessages @@ -5229,12 +5231,7 @@ function ExportPage() { > {!canExport ? '暂无会话' : isRunning ? '导出中...' : isQueued ? '排队中' : '单会话导出'} - {canExport && ( -
- 最近导出 - {recentExportTime} -
- )} + {hasRecentExport && {recentExportTime}} From 3de4951c9671275c83220580e9e639580d71074f Mon Sep 17 00:00:00 2001 From: aits2026 Date: Thu, 5 Mar 2026 20:25:53 +0800 Subject: [PATCH 31/31] fix(export): show top 15 entries in sns rank strip --- src/pages/ExportPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index f897e62..4f18a72 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -6112,7 +6112,7 @@ function ExportPage() { {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} ) : ( - sessionSnsActiveRankings.slice(0, 10).map((item, index) => ( + sessionSnsActiveRankings.slice(0, 15).map((item, index) => (
{index + 1} {item.name}