diff --git a/electron/main.ts b/electron/main.ts index b91f2ad..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' @@ -87,10 +87,12 @@ 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 let shouldShowMain = true +let isAppQuitting = false // 更新下载状态管理(Issue #294 修复) let isDownloadInProgress = false @@ -123,6 +125,47 @@ interface AnnualReportYearsTaskState { updatedAt: number } +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const normalizeSessionChatWindowOptionString = (value: unknown): string => { + return String(value || '').trim() +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export', + options?: OpenSessionChatWindowOptions +) => { + const queryParams = new URLSearchParams({ + sessionId, + source + }) + const initialDisplayName = normalizeSessionChatWindowOptionString(options?.initialDisplayName) + const initialAvatarUrl = normalizeSessionChatWindowOptionString(options?.initialAvatarUrl) + const initialContactType = normalizeSessionChatWindowOptionString(options?.initialContactType) + if (initialDisplayName) queryParams.set('initialDisplayName', initialDisplayName) + if (initialAvatarUrl) queryParams.set('initialAvatarUrl', initialAvatarUrl) + if (initialContactType) queryParams.set('initialContactType', initialContactType) + const query = queryParams.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() @@ -290,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 } @@ -688,12 +746,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, options) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } if (existing.isMinimized()) { existing.restore() } @@ -730,10 +794,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, options) + 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 +807,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 +818,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 +1132,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 +1471,7 @@ function registerIpcHandlers() { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) @@ -1463,6 +1525,10 @@ function registerIpcHandlers() { return snsService.getSnsUsernames() }) + ipcMain.handle('sns:getUserPostCounts', async () => { + return snsService.getUserPostCounts() + }) + ipcMain.handle('sns:getExportStats', async () => { return snsService.getExportStats() }) @@ -1471,6 +1537,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) }) @@ -2373,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/preload.ts b/electron/preload.ts index c173d10..41039e2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -99,8 +99,16 @@ 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' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: 'friend' | 'group' | 'official' | 'former_friend' | 'other' + } + ) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -174,7 +182,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), @@ -339,8 +353,10 @@ 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), 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/chatService.ts b/electron/services/chatService.ts index e50817e..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 { @@ -5209,39 +5210,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 + } } } @@ -5357,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( @@ -5380,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/electron/services/snsService.ts b/electron/services/snsService.ts index 1e0be35..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 @@ -864,6 +866,84 @@ 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 countsResult = await this.getUserPostCounts({ preferCache: true }) + if (countsResult.success) { + const totalPosts = countsResult.counts?.[normalizedUsername] ?? 0 + return { + success: true, + data: { + username: normalizedUsername, + totalPosts: Math.max(0, Number(totalPosts || 0)) + } + } + } + + return { success: false, error: countsResult.error || '统计单个好友朋友圈失败' } + } + // 安装朋友圈删除拦截 async installSnsBlockDeleteTrigger(): Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> { return wcdbService.installSnsBlockDeleteTrigger() @@ -881,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/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 diff --git a/src/App.tsx b/src/App.tsx index 9d040d2..a15bb1d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -402,8 +402,22 @@ 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') + const standaloneInitialDisplayName = params.get('initialDisplayName') + const standaloneInitialAvatarUrl = params.get('initialAvatarUrl') + const standaloneInitialContactType = params.get('initialContactType') + return ( + + ) } // 独立通知窗口 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/components/Sns/SnsPostItem.tsx b/src/components/Sns/SnsPostItem.tsx index 76972fb..7498b36 100644 --- a/src/components/Sns/SnsPostItem.tsx +++ b/src/components/Sns/SnsPostItem.tsx @@ -243,10 +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 }) => { +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) @@ -299,31 +301,56 @@ 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) } } + const handleOpenAuthorPosts = (e: React.MouseEvent) => { + e.stopPropagation() + onOpenAuthorPosts?.(post) + } + return ( <>
-
- -
+ {!hideAuthorMeta && ( +
+ +
+ )}
-
- {decodeHtmlEntities(post.nickname)} - {formatTime(post.createTime)} -
+ {hideAuthorMeta ? ( + {formatTime(post.createTime)} + ) : ( +
+ + {formatTime(post.createTime)} +
+ )}
{(mediaDeleted || dbDeleted) && ( diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d987e11..a21cafb 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1783,6 +1783,30 @@ z-index: 2; } +.standalone-phase-overlay { + position: absolute; + inset: 0; + z-index: 3; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + background: color-mix(in srgb, var(--bg-tertiary) 82%, transparent); + color: var(--text-secondary); + font-size: 14px; + pointer-events: none; + + .spin { + animation: spin 1s linear infinite; + } + + small { + color: var(--text-tertiary); + font-size: 12px; + } +} + .empty-chat-inline { display: flex; flex-direction: column; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 90df9aa..23d5ea5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -204,8 +204,13 @@ function formatYmdHmDateTime(timestamp?: number): string { interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null + standaloneSource?: string | null + standaloneInitialDisplayName?: string | null + standaloneInitialAvatarUrl?: string | null + standaloneInitialContactType?: string | null } +type StandaloneLoadStage = 'idle' | 'connecting' | 'loading' | 'ready' interface SessionDetail { wxid: string @@ -408,8 +413,20 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(props: ChatPageProps) { - const { standaloneSessionWindow = false, initialSessionId = null } = props + const { + standaloneSessionWindow = false, + initialSessionId = null, + standaloneSource = null, + standaloneInitialDisplayName = null, + standaloneInitialAvatarUrl = null, + standaloneInitialContactType = null + } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) + const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) + const normalizedStandaloneInitialDisplayName = useMemo(() => String(standaloneInitialDisplayName || '').trim(), [standaloneInitialDisplayName]) + const normalizedStandaloneInitialAvatarUrl = useMemo(() => String(standaloneInitialAvatarUrl || '').trim(), [standaloneInitialAvatarUrl]) + const normalizedStandaloneInitialContactType = useMemo(() => String(standaloneInitialContactType || '').trim().toLowerCase(), [standaloneInitialContactType]) + const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { @@ -493,7 +510,12 @@ function ChatPage(props: ChatPageProps) { const [hasInitialMessages, setHasInitialMessages] = useState(false) const [isSessionSwitching, setIsSessionSwitching] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) - const [fallbackDisplayName, setFallbackDisplayName] = useState(null) + const [fallbackDisplayName, setFallbackDisplayName] = useState(normalizedStandaloneInitialDisplayName || null) + const [fallbackAvatarUrl, setFallbackAvatarUrl] = useState(normalizedStandaloneInitialAvatarUrl || null) + const [standaloneLoadStage, setStandaloneLoadStage] = useState( + standaloneSessionWindow && normalizedInitialSessionId ? 'connecting' : 'idle' + ) + const [standaloneInitialLoadRequested, setStandaloneInitialLoadRequested] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [inProgressExportSessionIds, setInProgressExportSessionIds] = useState>(new Set()) @@ -2408,9 +2430,9 @@ function ChatPage(props: ChatPageProps) { }, [appendMessages, getMessageKey]) // 选择会话 - const selectSessionById = useCallback((sessionId: string) => { + const selectSessionById = useCallback((sessionId: string, options: { force?: boolean } = {}) => { const normalizedSessionId = String(sessionId || '').trim() - if (!normalizedSessionId || normalizedSessionId === currentSessionId) return + if (!normalizedSessionId || (!options.force && normalizedSessionId === currentSessionId)) return const switchRequestSeq = sessionSwitchRequestSeqRef.current + 1 sessionSwitchRequestSeqRef.current = switchRequestSeq @@ -2734,7 +2756,7 @@ function ChatPage(props: ChatPageProps) { }, [currentSessionId, messages.length, isLoadingMessages]) useEffect(() => { - if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { + if (currentSessionId && isConnected && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) { if (pendingSessionLoadRef.current === currentSessionId) return if (initialLoadRequestedSessionRef.current === currentSessionId) return initialLoadRequestedSessionRef.current = currentSessionId @@ -2745,7 +2767,7 @@ function ChatPage(props: ChatPageProps) { forceInitialLimit: 30 }) } - }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) + }, [currentSessionId, isConnected, messages.length, isLoadingMessages, isLoadingMore, noMessageTable]) useEffect(() => { return () => { @@ -2906,7 +2928,21 @@ function ChatPage(props: ChatPageProps) { // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback) const currentSession = (() => { const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined - if (found || !currentSessionId) return found + if (found) { + if ( + standaloneSessionWindow && + normalizedInitialSessionId && + found.username === normalizedInitialSessionId + ) { + return { + ...found, + displayName: found.displayName || fallbackDisplayName || found.username, + avatarUrl: found.avatarUrl || fallbackAvatarUrl || undefined + } + } + return found + } + if (!currentSessionId) return found return { username: currentSessionId, type: 0, @@ -2916,6 +2952,7 @@ function ChatPage(props: ChatPageProps) { lastTimestamp: 0, lastMsgType: 0, displayName: fallbackDisplayName || currentSessionId, + avatarUrl: fallbackAvatarUrl || undefined, } as ChatSession })() const filteredGroupPanelMembers = useMemo(() => { @@ -2935,33 +2972,121 @@ function ChatPage(props: ChatPageProps) { }, [groupMemberSearchKeyword, groupPanelMembers]) const isCurrentSessionExporting = Boolean(currentSessionId && inProgressExportSessionIds.has(currentSessionId)) const isExportActionBusy = isCurrentSessionExporting || isPreparingExportDialog + const isCurrentSessionGroup = Boolean( + currentSession && ( + isGroupChatSession(currentSession.username) || + ( + standaloneSessionWindow && + currentSession.username === normalizedInitialSessionId && + normalizedStandaloneInitialContactType === 'group' + ) + ) + ) + + useEffect(() => { + if (!standaloneSessionWindow) return + setStandaloneInitialLoadRequested(false) + setStandaloneLoadStage(normalizedInitialSessionId ? 'connecting' : 'idle') + setFallbackDisplayName(normalizedStandaloneInitialDisplayName || null) + setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl || null) + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialDisplayName, + normalizedStandaloneInitialAvatarUrl + ]) + + useEffect(() => { + if (!standaloneSessionWindow) return + if (!normalizedInitialSessionId) return + + if (normalizedStandaloneInitialDisplayName) { + setFallbackDisplayName(normalizedStandaloneInitialDisplayName) + } + if (normalizedStandaloneInitialAvatarUrl) { + setFallbackAvatarUrl(normalizedStandaloneInitialAvatarUrl) + } + + if (!currentSessionId) { + setCurrentSession(normalizedInitialSessionId, { preserveMessages: false }) + } + if (!isConnected || isConnecting) { + setStandaloneLoadStage('connecting') + } + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + normalizedStandaloneInitialDisplayName, + normalizedStandaloneInitialAvatarUrl, + currentSessionId, + isConnected, + isConnecting, + setCurrentSession + ]) useEffect(() => { if (!standaloneSessionWindow) return if (!normalizedInitialSessionId) return if (!isConnected || isConnecting) return - if (currentSessionId === normalizedInitialSessionId) return - selectSessionById(normalizedInitialSessionId) + if (currentSessionId === normalizedInitialSessionId && standaloneInitialLoadRequested) return + setStandaloneInitialLoadRequested(true) + setStandaloneLoadStage('loading') + selectSessionById(normalizedInitialSessionId, { + force: currentSessionId === normalizedInitialSessionId + }) }, [ standaloneSessionWindow, normalizedInitialSessionId, isConnected, isConnecting, currentSessionId, + standaloneInitialLoadRequested, selectSessionById ]) + useEffect(() => { + if (!standaloneSessionWindow || !normalizedInitialSessionId) return + if (!isConnected || isConnecting) { + setStandaloneLoadStage('connecting') + return + } + if (!standaloneInitialLoadRequested) { + setStandaloneLoadStage('loading') + return + } + if (currentSessionId !== normalizedInitialSessionId) { + setStandaloneLoadStage('loading') + return + } + if (isLoadingMessages || isSessionSwitching) { + setStandaloneLoadStage('loading') + return + } + setStandaloneLoadStage('ready') + }, [ + standaloneSessionWindow, + normalizedInitialSessionId, + isConnected, + isConnecting, + standaloneInitialLoadRequested, + currentSessionId, + isLoadingMessages, + isSessionSwitching + ]) + // 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称 useEffect(() => { if (!currentSessionId) return const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined if (found) { - setFallbackDisplayName(null) + if (found.displayName) setFallbackDisplayName(found.displayName) + if (found.avatarUrl) setFallbackAvatarUrl(found.avatarUrl) return } loadContactInfoBatch([currentSessionId]).then(() => { const cached = senderAvatarCache.get(currentSessionId) if (cached?.displayName) setFallbackDisplayName(cached.displayName) + if (cached?.avatarUrl) setFallbackAvatarUrl(cached.avatarUrl) }) }, [currentSessionId, sessions]) @@ -3738,16 +3863,16 @@ function ChatPage(props: ChatPageProps) { src={currentSession.avatarUrl} name={currentSession.displayName || currentSession.username} size={40} - className={isGroupChatSession(currentSession.username) ? 'group session-avatar' : 'session-avatar'} + className={isCurrentSessionGroup ? 'group session-avatar' : 'session-avatar'} />

{currentSession.displayName || currentSession.username}

- {isGroupChatSession(currentSession.username) && ( + {isCurrentSessionGroup && (
群聊
)}
- {!standaloneSessionWindow && isGroupChatSession(currentSession.username) && ( + {!standaloneSessionWindow && isCurrentSessionGroup && ( )} - {isGroupChatSession(currentSession.username) && ( + {isCurrentSessionGroup && ( - + {!shouldHideStandaloneDetailButton && ( + + )}
@@ -3881,6 +4008,13 @@ function ChatPage(props: ChatPageProps) { )}
+ {standaloneSessionWindow && standaloneLoadStage !== 'ready' && ( +
+ + {standaloneLoadStage === 'connecting' ? '正在建立连接...' : '正在加载最近消息...'} + {connectionError && {connectionError}} +
+ )} {isLoadingMessages && (!hasInitialMessages || isSessionSwitching) && (
@@ -3937,7 +4071,7 @@ function ChatPage(props: ChatPageProps) { session={currentSession} showTime={!showDateDivider && showTime} myAvatarUrl={myAvatarUrl} - isGroupChat={isGroupChatSession(currentSession.username)} + isGroupChat={isCurrentSessionGroup} onRequireModelDownload={handleRequireModelDownload} onContextMenu={handleContextMenu} isSelectionMode={isSelectionMode} @@ -3969,7 +4103,7 @@ function ChatPage(props: ChatPageProps) {
{/* 群成员面板 */} - {showGroupMembersPanel && isGroupChatSession(currentSession.username) && ( + {showGroupMembersPanel && isCurrentSessionGroup && (

群成员

diff --git a/src/pages/ExportPage.scss b/src/pages/ExportPage.scss index 270f0c0..3410552 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,152 @@ } } +.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(820px, 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: 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: 620px; + + &: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); + } +} + +.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 { + color: var(--text-tertiary); + flex-shrink: 0; +} + +.session-load-detail-progress-pulse { + 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; +} + .global-export-controls { background: var(--card-bg); border: 1px solid var(--border-color); @@ -995,6 +1162,7 @@ --contacts-default-list-height: calc(var(--contacts-row-height) * var(--contacts-default-visible-rows)); --contacts-select-col-width: 34px; --contacts-message-col-width: 120px; + --contacts-media-col-width: 72px; --contacts-action-col-width: 280px; overflow: hidden; border: 1px solid var(--border-color); @@ -1167,6 +1335,16 @@ text-overflow: ellipsis; } + .contacts-list-header-media { + width: var(--contacts-media-col-width); + min-width: var(--contacts-media-col-width); + text-align: center; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .contacts-list-header-actions { width: var(--contacts-action-col-width); display: flex; @@ -1347,12 +1525,72 @@ 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; } + .row-media-metric { + width: var(--contacts-media-col-width); + min-width: var(--contacts-media-col-width); + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + text-align: center; + } + + .row-media-metric-value { + margin: 0; + font-size: 12px; + line-height: 1.2; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 14px; + } + + .row-media-metric-icon { + 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; @@ -1397,6 +1635,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 { @@ -1532,7 +1794,7 @@ } } - .row-action-cell { +.row-action-cell { display: flex; flex-direction: column; align-items: flex-end; @@ -1542,37 +1804,10 @@ .row-action-main { display: inline-flex; - align-items: center; + align-items: flex-start; 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; @@ -1596,47 +1831,76 @@ } } - .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-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-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:hover:not(:disabled), + .row-export-link.state-disabled:focus-visible { + color: var(--text-tertiary); + text-decoration: none; } } @@ -1840,6 +2104,10 @@ } } + .detail-sns-entry-btn { + white-space: nowrap; + } + .copy-btn { display: flex; align-items: center; @@ -1951,6 +2219,333 @@ } } +.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); + } + + .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: 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); + 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; + 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-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; + overflow-y: auto; + padding: 12px 14px 14px; + } + + .export-session-sns-posts-list { + gap: 14px; + } + + .post-header-actions { + display: none; + } + + .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; @@ -2660,8 +3255,22 @@ font-size: 14px; } + .session-load-detail-entry { + 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; --contacts-action-col-width: 236px; } @@ -2688,6 +3297,10 @@ min-width: var(--contacts-message-col-width); } + .table-wrap .row-media-metric { + min-width: var(--contacts-media-col-width); + } + .table-wrap .row-message-stats { gap: 6px; } @@ -2700,10 +3313,19 @@ font-size: 11px; } + .table-wrap .row-media-metric-value { + font-size: 11px; + } + .table-wrap .row-message-stat.total .row-message-count-value { font-size: 12px; } + .table-wrap .row-open-chat-link, + .table-wrap .row-export-link { + font-size: 11px; + } + .export-dialog-overlay { padding: 10px; } @@ -2746,4 +3368,41 @@ .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-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: calc((28px * 15) + 16px); + } + + .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 3b56b13..4f18a72 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -38,6 +38,8 @@ 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' type ConversationTab = 'private' | 'group' | 'official' | 'former_friend' @@ -45,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' @@ -163,6 +166,14 @@ interface TimeRangeDialogDraft { } const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 +const SESSION_MEDIA_METRIC_PREFETCH_ROWS = 10 +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: '语音', @@ -171,6 +182,13 @@ const contentTypeLabels: Record = { emoji: '表情包' } +const conversationTabLabels: Record = { + private: '私聊', + group: '群聊', + official: '公众号', + former_friend: '曾经的好友' +} + const getContentTypeLabel = (type: ContentType): string => { return contentTypeLabels[type] || type } @@ -409,6 +427,14 @@ 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 formatPathBrief = (value: string, maxLength = 52): string => { const normalized = String(value || '') if (normalized.length <= maxLength) return normalized @@ -573,6 +599,10 @@ const isContentScopeSession = (session: SessionRow): boolean => ( session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' ) +const isExportConversationSession = (session: SessionRow): boolean => ( + session.kind === 'private' || session.kind === 'group' || session.kind === 'former_friend' +) + const exportKindPriority: Record = { private: 0, group: 1, @@ -648,6 +678,12 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } +interface SessionSnsTimelineTarget { + username: string + displayName: string + avatarUrl?: string +} + interface SessionExportMetric { totalMessages: number voiceMessages: number @@ -684,6 +720,30 @@ 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 + snsPostCounts: 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 { @@ -869,6 +929,48 @@ const normalizeMessageCount = (value: unknown): number | undefined => { return Math.floor(parsed) } +const pickSessionMediaMetric = ( + metricRaw: SessionExportMetric | SessionContentMetric | undefined +): SessionContentMetric | null => { + if (!metricRaw) return null + const voiceMessages = normalizeMessageCount(metricRaw.voiceMessages) + const imageMessages = normalizeMessageCount(metricRaw.imageMessages) + const videoMessages = normalizeMessageCount(metricRaw.videoMessages) + const emojiMessages = normalizeMessageCount(metricRaw.emojiMessages) + if ( + typeof voiceMessages !== 'number' && + typeof imageMessages !== 'number' && + typeof videoMessages !== 'number' && + typeof emojiMessages !== 'number' + ) { + return null + } + return { + voiceMessages, + imageMessages, + videoMessages, + emojiMessages + } +} + +const hasCompleteSessionMediaMetric = (metricRaw: SessionContentMetric | undefined): boolean => { + if (!metricRaw) return false + return ( + typeof normalizeMessageCount(metricRaw.voiceMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.imageMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.videoMessages) === 'number' && + typeof normalizeMessageCount(metricRaw.emojiMessages) === 'number' + ) +} + +const createDefaultSessionLoadStage = (): SessionLoadStageState => ({ status: 'pending' }) + +const createDefaultSessionLoadTrace = (): SessionLoadTraceState => ({ + messageCount: createDefaultSessionLoadStage(), + mediaMetrics: createDefaultSessionLoadStage(), + snsPostCounts: createDefaultSessionLoadStage() +}) + const WriteLayoutSelector = memo(function WriteLayoutSelector({ writeLayout, onChange, @@ -1208,19 +1310,33 @@ function ExportPage() { const [avatarCacheUpdatedAt, setAvatarCacheUpdatedAt] = useState(null) const [sessionMessageCounts, setSessionMessageCounts] = useState>({}) const [isLoadingSessionCounts, setIsLoadingSessionCounts] = useState(false) + 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) 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) 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 [sessionSnsRankMode, setSessionSnsRankMode] = useState(null) const [exportFolder, setExportFolder] = useState('') const [writeLayout, setWriteLayout] = useState('B') @@ -1298,6 +1414,7 @@ function ExportPage() { const sessionTableSectionRef = useRef(null) const detailRequestSeqRef = useRef(0) const sessionsRef = useRef([]) + const sessionContentMetricsRef = useRef>({}) const contactsListSizeRef = useRef(0) const contactsUpdatedAtRef = useRef(null) const sessionsHydratedAtRef = useRef(0) @@ -1306,7 +1423,29 @@ function ExportPage() { const activeTaskCountRef = useRef(0) const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) + const isLoadingSessionCountsRef = useRef(false) const activeTabRef = useRef('private') + const detailStatsPriorityRef = useRef(false) + 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([]) + const sessionMediaMetricQueuedSetRef = useRef>(new Set()) + const sessionMediaMetricLoadingSetRef = useRef>(new Set()) + const sessionMediaMetricReadySetRef = useRef>(new Set()) + const sessionMediaMetricRunIdRef = useRef(0) + const sessionMediaMetricWorkerRunningRef = useRef(false) + const sessionMediaMetricBackgroundFeedTimerRef = useRef(null) + const sessionMediaMetricPersistTimerRef = useRef(null) + const sessionMediaMetricPendingPersistRef = useRef>({}) + const sessionMediaMetricVisibleRangeRef = useRef<{ startIndex: number; endIndex: number }>({ + startIndex: 0, + endIndex: -1 + }) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1356,6 +1495,104 @@ function ExportPage() { contactsLoadTimeoutMsRef.current = contactsLoadTimeoutMs }, [contactsLoadTimeoutMs]) + useEffect(() => { + isLoadingSessionCountsRef.current = isLoadingSessionCounts + }, [isLoadingSessionCounts]) + + useEffect(() => { + 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 @@ -1577,6 +1814,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) @@ -1722,6 +1963,346 @@ function ExportPage() { } }, []) + const loadSnsUserPostCounts = useCallback(async (options?: { force?: boolean }) => { + 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 + } + + 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 = {} + try { + const result = await window.electronAPI.sns.getUserPostCounts() + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + if (!result.success || !result.counts) { + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { + error: result.error || '朋友圈条数统计失败' + }) + setSnsUserPostCountsStatus('error') + return + } + + 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 + } + + void (async () => { + try { + await configService.setExportSnsUserPostCountsCache(scopeKey, normalizedCounts) + } catch (cacheError) { + console.error('写入导出页朋友圈条数缓存失败:', cacheError) + } + })() + } catch (error) { + console.error('加载朋友圈用户条数失败:', error) + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + patchSessionLoadTraceStage(pendingSessionIds, 'snsPostCounts', 'failed', { + error: String(error) + }) + setSnsUserPostCountsStatus('error') + return + } + + let cursor = 0 + const applyBatch = () => { + if (runToken !== snsUserPostCountsHydrationTokenRef.current) return + + const batchSessionIds = pendingSessionIds.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() + }, [ensureExportCacheScope, patchSessionLoadTraceStage, 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 + setSessionSnsRankMode(null) + setSessionSnsTimelineTarget(null) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineLoading(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineTotalPosts(null) + setSessionSnsTimelineStatsLoading(false) + }, []) + + const openSessionSnsTimelineByTarget = useCallback((target: SessionSnsTimelineTarget) => { + setSessionSnsRankMode(null) + setSessionSnsTimelineTarget(target) + setSessionSnsTimelinePosts([]) + setSessionSnsTimelineHasMore(false) + setSessionSnsTimelineLoadingMore(false) + setSessionSnsTimelineLoading(false) + + if (snsUserPostCountsStatus === 'ready') { + const count = Number(snsUserPostCounts[target.username] || 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, + 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 }) + }, [ + 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 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 @@ -1818,6 +2399,194 @@ function ExportPage() { } }, []) + const resetSessionMediaMetricLoader = useCallback(() => { + sessionMediaMetricRunIdRef.current += 1 + sessionMediaMetricQueueRef.current = [] + sessionMediaMetricQueuedSetRef.current.clear() + sessionMediaMetricLoadingSetRef.current.clear() + sessionMediaMetricReadySetRef.current.clear() + sessionMediaMetricWorkerRunningRef.current = false + sessionMediaMetricPendingPersistRef.current = {} + sessionMediaMetricVisibleRangeRef.current = { startIndex: 0, endIndex: -1 } + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + if (sessionMediaMetricPersistTimerRef.current) { + window.clearTimeout(sessionMediaMetricPersistTimerRef.current) + sessionMediaMetricPersistTimerRef.current = null + } + }, []) + + const flushSessionMediaMetricCache = useCallback(async () => { + const pendingMetrics = sessionMediaMetricPendingPersistRef.current + sessionMediaMetricPendingPersistRef.current = {} + if (Object.keys(pendingMetrics).length === 0) return + + try { + const scopeKey = await ensureExportCacheScope() + const existing = await configService.getExportSessionContentMetricCache(scopeKey) + const nextMetrics = { + ...(existing?.metrics || {}), + ...pendingMetrics + } + await configService.setExportSessionContentMetricCache(scopeKey, nextMetrics) + } catch (error) { + console.error('写入导出页会话内容统计缓存失败:', error) + } + }, [ensureExportCacheScope]) + + const scheduleFlushSessionMediaMetricCache = useCallback(() => { + if (sessionMediaMetricPersistTimerRef.current) return + sessionMediaMetricPersistTimerRef.current = window.setTimeout(() => { + sessionMediaMetricPersistTimerRef.current = null + void flushSessionMediaMetricCache() + }, SESSION_MEDIA_METRIC_CACHE_FLUSH_DELAY_MS) + }, [flushSessionMediaMetricCache]) + + const isSessionMediaMetricReady = useCallback((sessionId: string): boolean => { + if (!sessionId) return true + if (sessionMediaMetricReadySetRef.current.has(sessionId)) return true + const existing = sessionContentMetricsRef.current[sessionId] + if (hasCompleteSessionMediaMetric(existing)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + return true + } + return false + }, []) + + const enqueueSessionMediaMetricRequests = useCallback((sessionIds: string[], options?: { front?: boolean }) => { + const front = options?.front === true + const incoming: string[] = [] + for (const sessionIdRaw of sessionIds) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + if (sessionMediaMetricQueuedSetRef.current.has(sessionId)) continue + if (sessionMediaMetricLoadingSetRef.current.has(sessionId)) continue + if (isSessionMediaMetricReady(sessionId)) continue + sessionMediaMetricQueuedSetRef.current.add(sessionId) + 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, patchSessionLoadTraceStage]) + + const applySessionMediaMetricsFromStats = useCallback((data?: Record) => { + if (!data) return + const nextMetrics: Record = {} + let hasPatch = false + for (const [sessionIdRaw, metricRaw] of Object.entries(data)) { + const sessionId = String(sessionIdRaw || '').trim() + if (!sessionId) continue + const metric = pickSessionMediaMetric(metricRaw) + if (!metric) continue + nextMetrics[sessionId] = metric + hasPatch = true + sessionMediaMetricPendingPersistRef.current[sessionId] = { + ...sessionMediaMetricPendingPersistRef.current[sessionId], + ...metric + } + if (hasCompleteSessionMediaMetric(metric)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + } + } + + if (hasPatch) { + mergeSessionContentMetrics(nextMetrics) + scheduleFlushSessionMediaMetricCache() + } + }, [mergeSessionContentMetrics, scheduleFlushSessionMediaMetricCache]) + + const runSessionMediaMetricWorker = useCallback(async (runId: number) => { + if (sessionMediaMetricWorkerRunningRef.current) return + sessionMediaMetricWorkerRunningRef.current = true + try { + while (runId === sessionMediaMetricRunIdRef.current) { + if (isLoadingSessionCountsRef.current || detailStatsPriorityRef.current) { + await new Promise(resolve => window.setTimeout(resolve, 80)) + continue + } + + if (sessionMediaMetricQueueRef.current.length === 0) break + + const batchSessionIds: string[] = [] + while (batchSessionIds.length < SESSION_MEDIA_METRIC_BATCH_SIZE && sessionMediaMetricQueueRef.current.length > 0) { + const nextId = sessionMediaMetricQueueRef.current.shift() + if (!nextId) continue + sessionMediaMetricQueuedSetRef.current.delete(nextId) + if (sessionMediaMetricLoadingSetRef.current.has(nextId)) continue + if (isSessionMediaMetricReady(nextId)) continue + sessionMediaMetricLoadingSetRef.current.add(nextId) + batchSessionIds.push(nextId) + } + if (batchSessionIds.length === 0) { + continue + } + patchSessionLoadTraceStage(batchSessionIds, 'mediaMetrics', 'loading') + + try { + const cacheResult = await window.electronAPI.chat.getExportSessionStats( + batchSessionIds, + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + if (runId !== sessionMediaMetricRunIdRef.current) return + if (cacheResult.success && cacheResult.data) { + applySessionMediaMetricsFromStats(cacheResult.data as Record) + } + + const missingSessionIds = batchSessionIds.filter(sessionId => !isSessionMediaMetricReady(sessionId)) + if (missingSessionIds.length > 0) { + const freshResult = await window.electronAPI.chat.getExportSessionStats( + missingSessionIds, + { includeRelations: false, allowStaleCache: true } + ) + if (runId !== sessionMediaMetricRunIdRef.current) return + if (freshResult.success && freshResult.data) { + applySessionMediaMetricsFromStats(freshResult.data as Record) + } + } + } 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)) + } + } finally { + sessionMediaMetricWorkerRunningRef.current = false + if (runId === sessionMediaMetricRunIdRef.current && sessionMediaMetricQueueRef.current.length > 0) { + void runSessionMediaMetricWorker(runId) + } + } + }, [applySessionMediaMetricsFromStats, isSessionMediaMetricReady, patchSessionLoadTraceStage]) + + const scheduleSessionMediaMetricWorker = useCallback(() => { + if (!isSessionCountStageReady) return + if (isLoadingSessionCountsRef.current) return + if (sessionMediaMetricWorkerRunningRef.current) return + const runId = sessionMediaMetricRunIdRef.current + void runSessionMediaMetricWorker(runId) + }, [isSessionCountStageReady, runSessionMediaMetricWorker]) + const loadSessionMessageCounts = useCallback(async ( sourceSessions: SessionRow[], priorityTab: ConversationTab, @@ -1829,8 +2598,12 @@ function ExportPage() { const requestId = sessionCountRequestIdRef.current + 1 sessionCountRequestIdRef.current = requestId const isStale = () => sessionCountRequestIdRef.current !== requestId + 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') { @@ -1839,12 +2612,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) @@ -1859,6 +2637,9 @@ function ExportPage() { if (exportableSessions.length === 0) { setIsLoadingSessionCounts(false) + if (!isStale()) { + setIsSessionCountStageReady(true) + } return { ...accumulatedCounts } } @@ -1894,26 +2675,54 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { + if (detailStatsPriorityRef.current) { + 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 || '总消息数加载失败' } + ) } } + if (detailStatsPriorityRef.current) { + 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) + setIsSessionCountStageReady(true) if (options?.scopeKey && Object.keys(accumulatedCounts).length > 0) { try { await configService.setExportSessionMessageCountCache(options.scopeKey, accumulatedCounts) @@ -1924,18 +2733,31 @@ function ExportPage() { } } return { ...accumulatedCounts } - }, [mergeSessionContentMetrics]) + }, [mergeSessionContentMetrics, patchSessionLoadTraceStage]) const loadSessions = useCallback(async () => { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken sessionsHydratedAtRef.current = 0 + sessionPreciseRefreshAtRef.current = {} + resetSessionMediaMetricLoader() setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 setSessionMessageCounts({}) setSessionContentMetrics({}) + setSessionLoadTraceMap({}) + setSessionLoadProgressPulseMap({}) + sessionLoadProgressSnapshotRef.current = {} + snsUserPostCountsHydrationTokenRef.current += 1 + if (snsUserPostCountsBatchTimerRef.current) { + window.clearTimeout(snsUserPostCountsBatchTimerRef.current) + snsUserPostCountsBatchTimerRef.current = null + } + setSnsUserPostCounts({}) + setSnsUserPostCountsStatus('idle') setIsLoadingSessionCounts(false) + setIsSessionCountStageReady(false) const isStale = () => sessionLoadTokenRef.current !== loadToken @@ -1945,10 +2767,12 @@ function ExportPage() { const [ cachedContactsPayload, - cachedMessageCountsPayload + cachedMessageCountsPayload, + cachedContentMetricsPayload ] = await Promise.all([ loadContactsCaches(scopeKey), - configService.getExportSessionMessageCountCache(scopeKey) + configService.getExportSessionMessageCountCache(scopeKey), + configService.getExportSessionContentMetricCache(scopeKey) ]) if (isStale()) return @@ -1962,7 +2786,7 @@ function ExportPage() { const cachedContactMap = toContactMapFromCaches(cachedContacts, cachedAvatarEntries) if (cachedContacts.length > 0) { syncContactTypeCounts(Object.values(cachedContactMap)) - setSessions(toSessionRowsWithContacts([], cachedContactMap)) + setSessions(toSessionRowsWithContacts([], cachedContactMap).filter(isExportConversationSession)) setSessionDataSource('cache') setIsLoading(false) } @@ -1981,7 +2805,7 @@ function ExportPage() { if (sessionsResult.success && sessionsResult.sessions) { const rawSessions = sessionsResult.sessions - const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap) + const baseSessions = toSessionRowsWithContacts(rawSessions, cachedContactMap).filter(isExportConversationSession) const exportableSessionIds = baseSessions .filter((session) => session.hasSession) .map((session) => session.username) @@ -2000,6 +2824,22 @@ function ExportPage() { acc[sessionId] = { totalMessages: count } return acc }, {}) + const cachedContentMetrics = Object.entries(cachedContentMetricsPayload?.metrics || {}).reduce>((acc, [sessionId, rawMetric]) => { + if (!exportableSessionIdSet.has(sessionId)) return acc + const metric = pickSessionMediaMetric(rawMetric) + if (!metric) return acc + acc[sessionId] = metric + if (hasCompleteSessionMediaMetric(metric)) { + sessionMediaMetricReadySetRef.current.add(sessionId) + } + return acc + }, {}) + const cachedContentMetricReadySessionIds = Object.entries(cachedContentMetrics) + .filter(([, metric]) => hasCompleteSessionMediaMetric(metric)) + .map(([sessionId]) => sessionId) + if (cachedContentMetricReadySessionIds.length > 0) { + patchSessionLoadTraceStage(cachedContentMetricReadySessionIds, 'mediaMetrics', 'done') + } if (isStale()) return if (Object.keys(cachedMessageCounts).length > 0) { @@ -2008,6 +2848,9 @@ function ExportPage() { if (Object.keys(cachedCountAsMetrics).length > 0) { mergeSessionContentMetrics(cachedCountAsMetrics) } + if (Object.keys(cachedContentMetrics).length > 0) { + mergeSessionContentMetrics(cachedContentMetrics) + } setSessions(baseSessions) sessionsHydratedAtRef.current = Date.now() void (async () => { @@ -2027,12 +2870,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 +2936,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 { @@ -2152,7 +2998,7 @@ function ExportPage() { }, contactMap) if (isStale()) return - const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap) + const nextSessions = toSessionRowsWithContacts(rawSessions, contactMap).filter(isExportConversationSession) .map((session) => { const extra = extraContactMap[session.username] const displayName = extra?.displayName || session.displayName || session.username @@ -2205,7 +3051,7 @@ function ExportPage() { } finally { if (!isStale()) setIsLoading(false) } - }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, syncContactTypeCounts]) + }, [ensureExportCacheScope, loadContactsCaches, loadSessionMessageCounts, mergeSessionContentMetrics, patchSessionLoadTraceStage, resetSessionMediaMetricLoader, syncContactTypeCounts]) useEffect(() => { if (!isExportRoute) return @@ -2237,10 +3083,22 @@ 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(prev => (prev === 'loading' ? 'idle' : prev)) }, [isExportRoute]) + useEffect(() => { + if (activeTab === 'official') { + setActiveTab('private') + } + }, [activeTab]) + useEffect(() => { activeTabRef.current = activeTab }, [activeTab]) @@ -3280,9 +4138,11 @@ function ExportPage() { const activeTabLabel = useMemo(() => { if (activeTab === 'private') return '私聊' if (activeTab === 'group') return '群聊' - if (activeTab === 'former_friend') return '曾经的好友' - return '公众号' + return '曾经的好友' }, [activeTab]) + const shouldShowSnsColumn = useMemo(() => ( + activeTab === 'private' || activeTab === 'former_friend' + ), [activeTab]) const sessionRowByUsername = useMemo(() => { const map = new Map() @@ -3336,11 +4196,297 @@ 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 + ): 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 + } + 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: loaded >= total ? latestFinish : undefined, + latestProgressAt + } + }, [getLoadDetailStatusLabel, sessionLoadTraceMap]) + + const createNotApplicableLoadSummary = useCallback((): SessionLoadStageSummary => { + return { + total: 0, + loaded: 0, + statusLabel: '不适用' + } + }, []) + + const sessionLoadDetailRows = useMemo(() => { + const tabOrder: ConversationTab[] = ['private', 'group', 'former_friend'] + return tabOrder.map((tab) => { + const sessionIds = loadDetailTargetsByTab[tab] || [] + const snsSessionIds = sessionIds.filter((sessionId) => isSingleContactSession(sessionId)) + const snsPostCounts = tab === 'private' + ? summarizeLoadTraceForTab(snsSessionIds, 'snsPostCounts') + : createNotApplicableLoadSummary() + return { + tab, + label: conversationTabLabels[tab], + messageCount: summarizeLoadTraceForTab(sessionIds, 'messageCount'), + mediaMetrics: summarizeLoadTraceForTab(sessionIds, 'mediaMetrics'), + snsPostCounts + } + }) + }, [createNotApplicableLoadSummary, 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', 'snsPostCounts'] + + 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) }, [activeTab, searchKeyword]) + const collectVisibleSessionMetricTargets = useCallback((sourceContacts: ContactInfo[]): string[] => { + if (sourceContacts.length === 0) return [] + const startCandidate = sessionMediaMetricVisibleRangeRef.current.startIndex + const endCandidate = sessionMediaMetricVisibleRangeRef.current.endIndex + const startIndex = Math.max(0, Math.min(sourceContacts.length - 1, startCandidate >= 0 ? startCandidate : 0)) + const visibleEnd = endCandidate >= startIndex + ? endCandidate + : Math.min(sourceContacts.length - 1, startIndex + 9) + const endIndex = Math.max(startIndex, Math.min(sourceContacts.length - 1, visibleEnd + SESSION_MEDIA_METRIC_PREFETCH_ROWS)) + const sessionIds: string[] = [] + for (let index = startIndex; index <= endIndex; index += 1) { + const contact = sourceContacts[index] + if (!contact?.username) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + sessionIds.push(contact.username) + } + return sessionIds + }, [sessionRowByUsername]) + + const handleContactsRangeChanged = useCallback((range: { startIndex: number; endIndex: number }) => { + const startIndex = Number.isFinite(range?.startIndex) ? Math.max(0, Math.floor(range.startIndex)) : 0 + const endIndex = Number.isFinite(range?.endIndex) ? Math.max(startIndex, Math.floor(range.endIndex)) : startIndex + sessionMediaMetricVisibleRangeRef.current = { startIndex, endIndex } + if (isLoadingSessionCountsRef.current || !isSessionCountStageReady) return + const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) + if (visibleTargets.length === 0) return + enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) + scheduleSessionMediaMetricWorker() + }, [collectVisibleSessionMetricTargets, enqueueSessionMediaMetricRequests, filteredContacts, isSessionCountStageReady, scheduleSessionMediaMetricWorker]) + + useEffect(() => { + if (!isSessionCountStageReady || filteredContacts.length === 0) return + const runId = sessionMediaMetricRunIdRef.current + const visibleTargets = collectVisibleSessionMetricTargets(filteredContacts) + if (visibleTargets.length > 0) { + enqueueSessionMediaMetricRequests(visibleTargets, { front: true }) + scheduleSessionMediaMetricWorker() + } + + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + + const visibleTargetSet = new Set(visibleTargets) + let cursor = 0 + const feedNext = () => { + if (runId !== sessionMediaMetricRunIdRef.current) return + if (isLoadingSessionCountsRef.current) return + const batchIds: string[] = [] + while (cursor < filteredContacts.length && batchIds.length < SESSION_MEDIA_METRIC_BACKGROUND_FEED_SIZE) { + const contact = filteredContacts[cursor] + cursor += 1 + if (!contact?.username) continue + if (visibleTargetSet.has(contact.username)) continue + const mappedSession = sessionRowByUsername.get(contact.username) + if (!mappedSession?.hasSession) continue + batchIds.push(contact.username) + } + + if (batchIds.length > 0) { + enqueueSessionMediaMetricRequests(batchIds) + scheduleSessionMediaMetricWorker() + } + + if (cursor < filteredContacts.length) { + sessionMediaMetricBackgroundFeedTimerRef.current = window.setTimeout(feedNext, SESSION_MEDIA_METRIC_BACKGROUND_FEED_INTERVAL_MS) + } + } + + feedNext() + return () => { + if (sessionMediaMetricBackgroundFeedTimerRef.current) { + window.clearTimeout(sessionMediaMetricBackgroundFeedTimerRef.current) + sessionMediaMetricBackgroundFeedTimerRef.current = null + } + } + }, [ + collectVisibleSessionMetricTargets, + enqueueSessionMediaMetricRequests, + filteredContacts, + isSessionCountStageReady, + scheduleSessionMediaMetricWorker, + sessionRowByUsername + ]) + + 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 + } + if (sessionMediaMetricPersistTimerRef.current) { + window.clearTimeout(sessionMediaMetricPersistTimerRef.current) + sessionMediaMetricPersistTimerRef.current = null + } + void flushSessionMediaMetricCache() + } + }, [flushSessionMediaMetricCache]) + const contactByUsername = useMemo(() => { const map = new Map() for (const contact of contactsList) { @@ -3358,6 +4504,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, @@ -3399,6 +4566,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 +4682,13 @@ function ExportPage() { } try { - const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ - window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: 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,51 +4698,73 @@ function ExportPage() { messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] } }) + } catch (error) { + console.error('导出页加载会话详情补充信息失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + 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) } - 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) - } 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) - } + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 + const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS + const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) - if (shouldRefreshStats) { + if (shouldRunPreciseRefresh) { setIsRefreshingSessionDetailStats(true) void (async () => { try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 const freshResult = await window.electronAPI.chat.getExportSessionStats( [normalizedSessionId], - { includeRelations: refreshIncludeRelations, forceRefresh: true, preferAccurateSpecialTypes: true } + { 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, - refreshIncludeRelations ? true : undefined - ) + 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) { @@ -3590,7 +4778,6 @@ function ExportPage() { } } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) - } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingSessionDetailExtra(false) } @@ -3619,36 +4806,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 { @@ -3658,20 +4815,78 @@ function ExportPage() { } }, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid]) + useEffect(() => { + if (!showSessionDetailPanel || !sessionDetailSupportsSnsTimeline) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + }, [ + loadSnsUserPostCounts, + sessionDetailSupportsSnsTimeline, + showSessionDetailPanel, + 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') { + 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) => { if (!sessionId) return + detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) + if (isSingleContactSession(sessionId)) { + void loadSnsUserPostCounts() + } void loadSessionDetail(sessionId) - }, [loadSessionDetail]) + }, [loadSessionDetail, loadSnsUserPostCounts]) useEffect(() => { if (!showSessionDetailPanel) return @@ -3684,6 +4899,31 @@ function ExportPage() { return () => window.removeEventListener('keydown', handleKeyDown) }, [closeSessionDetailPanel, showSessionDetailPanel]) + useEffect(() => { + if (!showSessionLoadDetailModal) return + if (snsUserPostCountsStatus === 'idle') { + void loadSnsUserPostCounts() + } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowSessionLoadDetailModal(false) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [loadSnsUserPostCounts, showSessionLoadDetailModal, snsUserPostCountsStatus]) + + 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) @@ -3798,6 +5038,22 @@ 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, + row.snsPostCounts.finishedAt || row.snsPostCounts.startedAt || 0 + ] + for (const candidate of candidateTimes) { + if (candidate > latest) { + latest = candidate + } + } + } + return latest + }, [sessionLoadDetailRows]) const closeTaskCenter = useCallback(() => { setIsTaskCenterOpen(false) setExpandedPerfTaskId(null) @@ -3811,15 +5067,50 @@ 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 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 + const mediaMetric = sessionContentMetrics[contact.username] const messageCountLabel = !canExport ? '--' : typeof displayedMessageCount === 'number' ? displayedMessageCount.toLocaleString('zh-CN') : '获取中' + const metricToDisplay = (value: unknown): { state: 'value'; text: string } | { state: 'loading' } | { state: 'na'; text: '--' } => { + const normalized = normalizeMessageCount(value) + if (!canExport) return { state: 'na', text: '--' } + if (typeof normalized === 'number') { + return { state: 'value', text: normalized.toLocaleString('zh-CN') } + } + return { state: 'loading' } + } + const emojiMetric = metricToDisplay(mediaMetric?.emojiMessages) + const voiceMetric = metricToDisplay(mediaMetric?.voiceMessages) + const imageMetric = metricToDisplay(mediaMetric?.imageMessages) + const videoMetric = metricToDisplay(mediaMetric?.videoMessages) + 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 openChatLabel = contact.type === 'friend' + ? '打开私聊' + : contact.type === 'group' + ? '打开群聊' + : '打开对话' return (
@@ -3851,47 +5142,98 @@ function ExportPage() { {messageCountLabel}
-
-
-
+ {canExport && ( + )} +
+
+ + {emojiMetric.state === 'loading' + ? + : emojiMetric.text} + +
+
+ + {voiceMetric.state === 'loading' + ? + : voiceMetric.text} + +
+
+ + {imageMetric.state === 'loading' + ? + : imageMetric.text} + +
+
+ + {videoMetric.state === 'loading' + ? + : videoMetric.text} + +
+ {shouldShowSnsColumn && ( +
+ {supportsSnsTimeline ? ( + + ) : ( + -- + )} +
+ )} +
+
- +
+ + {hasRecentExport && {recentExportTime}} +
- {recent && {recent}}
@@ -3899,15 +5241,21 @@ function ExportPage() { }, [ lastExportBySession, nowTick, + openContactSnsTimeline, openSessionDetail, openSingleExport, queuedSessionIds, runningSessionIds, selectedSessions, sessionDetail?.wxid, + sessionContentMetrics, + sessionLoadTraceMap, sessionMessageCounts, sessionRowByUsername, showSessionDetailPanel, + shouldShowSnsColumn, + snsUserPostCounts, + snsUserPostCountsStatus, toggleSelectSession ]) const handleContactsListWheelCapture = useCallback((event: WheelEvent) => { @@ -4094,6 +5442,14 @@ function ExportPage() { '你可以先在列表中筛选目标会话,再批量导出,结果会保留每个会话的结构与时间线。' ]} /> +
@@ -4107,9 +5463,6 @@ function ExportPage() { - @@ -4160,6 +5513,13 @@ function ExportPage() { 联系人(头像/名称/微信号) 总消息数 + 表情包 + 语音 + 图片 + 视频 + {shouldShowSnsColumn && ( + 朋友圈 + )} {selectedCount > 0 && ( <> @@ -4237,6 +5597,7 @@ function ExportPage() { data={filteredContacts} computeItemKey={(_, contact) => contact.username} itemContent={renderContactRow} + rangeChanged={handleContactsRangeChanged} atTopStateChange={setIsContactsListAtTop} overscan={420} /> @@ -4244,6 +5605,147 @@ function ExportPage() { )}
+ {showSessionLoadDetailModal && ( +
setShowSessionLoadDetailModal(false)} + > +
event.stopPropagation()} + > +
+
+

数据加载详情

+

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

+
+ +
+ +
+
+
总消息数
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {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)} +
+ ) + })} +
+
+ +
+
多媒体统计(表情包/图片/视频/语音)
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {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)} +
+ ) + })} +
+
+ +
+
朋友圈条数统计
+
+
+ 会话类型 + 加载状态 + 开始时间 + 完成时间 +
+ {sessionLoadDetailRows + .filter((row) => row.tab === 'private') + .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)} +
+ ) + })} +
+
+
+
+
+ )} + {showSessionDetailPanel && (
)} + {sessionDetailSupportsSnsTimeline && ( +
+ + 朋友圈 + + + +
+ )}
@@ -4543,6 +6060,125 @@ function ExportPage() {
)} + + {sessionSnsTimelineTarget && ( +
+
event.stopPropagation()} + > +
+
+
+ {sessionSnsTimelineTarget.avatarUrl ? ( + + ) : ( + {getAvatarLetter(sessionSnsTimelineTarget.displayName || sessionSnsTimelineTarget.username)} + )} +
+
+

{sessionSnsTimelineTarget.displayName}

+
@{sessionSnsTimelineTarget.username}
+
{renderSessionSnsTimelineStats()}
+
+
+
+
+ + + {sessionSnsRankMode && ( +
+ {sessionSnsActiveRankings.length === 0 ? ( +
+ {sessionSnsRankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'} +
+ ) : ( + sessionSnsActiveRankings.slice(0, 15).map((item, index) => ( +
+ {index + 1} + {item.name} + + {item.count.toLocaleString('zh-CN')} + {sessionSnsRankMode === 'likes' ? '次' : '条'} + +
+ )) + )} +
+ )} +
+ +
+
+ +
+ 在微信桌面客户端中打开这个人的朋友圈浏览,可快速把其朋友圈同步到这里。若你在乎这个人,一定要试试~ +
+ +
+ {sessionSnsTimelinePosts.length > 0 && ( +
+ {sessionSnsTimelinePosts.map((post) => ( + { + if (isVideo) { + void window.electronAPI.window.openVideoPlayerWindow(src) + } else { + void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined) + } + }} + onDebug={() => {}} + hideAuthorMeta + /> + ))} +
+ )} + + {sessionSnsTimelineLoading && ( +
正在加载该联系人的朋友圈...
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelinePosts.length === 0 && ( +
该联系人暂无朋友圈
+ )} + + {!sessionSnsTimelineLoading && sessionSnsTimelineHasMore && ( + + )} +
+
+
+ )}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index bc52b0c..34febde 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); @@ -219,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; @@ -1043,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; @@ -1120,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; + } + } } } @@ -1317,6 +1414,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..f088e76 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -5,17 +5,31 @@ 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 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 { @@ -28,6 +42,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) @@ -51,10 +71,22 @@ 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) 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 [authorTimelineTotalPosts, setAuthorTimelineTotalPosts] = useState(null) + const [authorTimelineStatsLoading, setAuthorTimelineStatsLoading] = useState(false) // 导出相关状态 const [showExportDialog, setShowExportDialog] = useState(false) @@ -81,19 +113,30 @@ 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) + const contactsCountBatchTimerRef = useRef(null) + const authorTimelinePostsRef = useRef([]) + const authorTimelineLoadingRef = useRef(false) + const authorTimelineRequestTokenRef = useRef(0) + const authorTimelineStatsTokenRef = useRef(0) // Sync posts ref useEffect(() => { postsRef.current = posts }, [posts]) + useEffect(() => { + contactsRef.current = contacts + }, [contacts]) useEffect(() => { overviewStatsRef.current = overviewStats }, [overviewStats]) @@ -109,6 +152,9 @@ export default function SnsPage() { useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) + useEffect(() => { + authorTimelinePostsRef.current = authorTimelinePosts + }, [authorTimelinePosts]) // 在 DOM 更新后、浏览器绘制前同步调整滚动位置,防止向上加载时页面跳动 useLayoutEffect(() => { const snapshot = scrollAdjustmentRef.current; @@ -132,6 +178,43 @@ 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 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 }, []) @@ -144,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 { @@ -275,8 +373,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' } = {}) => { @@ -390,31 +487,235 @@ 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[], + options?: { force?: boolean; readyUsernames?: Set } + ) => { + const force = options?.force === true + const targets = usernames + .map((username) => String(username || '').trim()) + .filter(Boolean) + stopContactsCountHydration(true) + if (targets.length === 0) return + + const readySet = options?.readyUsernames || 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(pendingTargets) + + 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 + }) + } + const preResolved = Math.max(0, totalTargets - pendingTargets.length) + setContactsCountProgress({ + resolved: preResolved, + total: totalTargets, + running: pendingTargets.length > 0 + }) + if (pendingTargets.length === 0) return + + 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)]) + ) + 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 = preResolved + let cursor = 0 + const applyBatch = () => { + if (runToken !== contactsCountHydrationTokenRef.current) return + + const batch = pendingTargets.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 snsPostCountsScopeKey = await ensureSnsUserPostCountsCacheScopeKey() + 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() + ]) 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) { 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' + type: c.type === 'former_friend' ? 'former_friend' : 'friend', + lastSessionTimestamp: Number(sessionTimestampMap.get(c.username) || 0), + postCount: hasCachedCount ? Math.max(0, Math.floor(cachedCount)) : undefined, + postCountStatus: hasCachedCount ? 'ready' : 'idle' }) } } } - let contactsList = Array.from(contactMap.values()) - + let contactsList = sortContactsForRanking(Array.from(contactMap.values())) if (requestToken !== contactsLoadTokenRef.current) return setContacts(contactsList) + 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) @@ -422,7 +723,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 { @@ -432,19 +733,178 @@ 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) } } + }, [ensureSnsUserPostCountsCacheScopeKey, hydrateContactPostCounts, sortContactsForRanking, stopContactsCountHydration]) + + 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.getUserPostCounts() + if (requestToken !== authorTimelineStatsTokenRef.current) return + + if (result.success && result.counts) { + const totalPosts = result.counts[target.username] ?? 0 + setAuthorTimelineTotalPosts(Math.max(0, Number(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 } = {}) => { + 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) + setAuthorTimelineTotalPosts(null) + void loadAuthorTimelinePosts(target, { reset: true }) + 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, 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() + }, [authorTimelineTarget, loadOverviewStats, persistSnsPageCache]) + // Initial Load & Listeners useEffect(() => { void hydrateSnsPageCache() @@ -452,10 +912,23 @@ 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 = '' + snsUserPostCountsCacheScopeKeyRef.current = '' // wxid changed, reset everything + stopContactsCountHydration(true) + setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); setSelectedUsernames([]); setSearchKeyword(''); setJumpTargetDate(undefined); void hydrateSnsPageCache() @@ -465,7 +938,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(() => { @@ -474,6 +947,24 @@ 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]) + + 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) { @@ -492,6 +983,29 @@ export default function SnsPage() { } } + const handleAuthorTimelineScroll = (e: React.UIEvent) => { + const { scrollTop, clientHeight, scrollHeight } = e.currentTarget + if (scrollHeight - scrollTop - clientHeight < 260) { + loadMoreAuthorTimeline() + } + } + + const renderAuthorTimelineStats = () => { + 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 + return `${loadPart} | ${formatDateOnly(earliest)} ~ ${formatDateOnly(latest)}` + } + return (
@@ -578,14 +1092,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} /> ))}
@@ -644,6 +1152,7 @@ export default function SnsPage() { contactSearch={contactSearch} setContactSearch={setContactSearch} loading={contactsLoading} + contactsCountProgress={contactsCountProgress} /> {/* Dialogs and Overlays */} @@ -657,6 +1166,77 @@ 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} + hideAuthorMeta + /> + ))} +
+ )} + + {authorTimelineLoading && ( +
+ + 正在加载该用户朋友圈... +
+ )} + + {!authorTimelineLoading && authorTimelinePosts.length === 0 && ( +
该用户暂无朋友圈
+ )} + + {!authorTimelineLoading && authorTimelineHasMore && ( + + )} +
+
+
+ )} + {debugPost && (
setDebugPost(null)}>
e.stopPropagation()}> 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) diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a3abc38..72aaa57 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,5 +1,12 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' +export interface SessionChatWindowOpenOptions { + source?: 'chat' | 'export' + initialDisplayName?: string + initialAvatarUrl?: string + initialContactType?: ContactInfo['type'] +} + export interface ElectronAPI { window: { minimize: () => void @@ -13,7 +20,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 +257,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 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 }> installBlockDeleteTrigger: () => Promise<{ success: boolean; alreadyInstalled?: boolean; error?: string }> uninstallBlockDeleteTrigger: () => Promise<{ success: boolean; error?: string }> checkBlockDeleteTrigger: () => Promise<{ success: boolean; installed?: boolean; error?: string }>