diff --git a/electron/main.ts b/electron/main.ts index b91f2ad..7770856 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -87,6 +87,7 @@ let onboardingWindow: BrowserWindow | null = null // Splash 启动窗口 let splashWindow: BrowserWindow | null = null const sessionChatWindows = new Map() +const sessionChatWindowSources = new Map() const keyService = new KeyService() let mainWindowReady = false @@ -123,6 +124,32 @@ interface AnnualReportYearsTaskState { updatedAt: number } +interface OpenSessionChatWindowOptions { + source?: 'chat' | 'export' +} + +const normalizeSessionChatWindowSource = (source: unknown): 'chat' | 'export' => { + return String(source || '').trim().toLowerCase() === 'export' ? 'export' : 'chat' +} + +const loadSessionChatWindowContent = ( + win: BrowserWindow, + sessionId: string, + source: 'chat' | 'export' +) => { + const query = new URLSearchParams({ + sessionId, + source + }).toString() + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${query}`) + return + } + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/chat-window?${query}` + }) +} + const annualReportYearsLoadTasks = new Map() const annualReportYearsTaskByCacheKey = new Map() const annualReportYearsSnapshotCache = new Map() @@ -688,12 +715,18 @@ function createChatHistoryWindow(sessionId: string, messageId: number) { /** * 创建独立的会话聊天窗口(单会话,复用聊天页右侧消息区域) */ -function createSessionChatWindow(sessionId: string) { +function createSessionChatWindow(sessionId: string, options?: OpenSessionChatWindowOptions) { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return null + const normalizedSource = normalizeSessionChatWindowSource(options?.source) const existing = sessionChatWindows.get(normalizedSessionId) if (existing && !existing.isDestroyed()) { + const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat' + if (trackedSource !== normalizedSource) { + loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) + } if (existing.isMinimized()) { existing.restore() } @@ -730,10 +763,9 @@ function createSessionChatWindow(sessionId: string) { autoHideMenuBar: true }) - const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` - if (process.env.VITE_DEV_SERVER_URL) { - win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`) + loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource) + if (process.env.VITE_DEV_SERVER_URL) { win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (win.webContents.isDevToolsOpened()) { @@ -744,10 +776,6 @@ function createSessionChatWindow(sessionId: string) { event.preventDefault() } }) - } else { - win.loadFile(join(__dirname, '../dist/index.html'), { - hash: `/chat-window?${sessionParam}` - }) } win.once('ready-to-show', () => { @@ -759,10 +787,12 @@ function createSessionChatWindow(sessionId: string) { const tracked = sessionChatWindows.get(normalizedSessionId) if (tracked === win) { sessionChatWindows.delete(normalizedSessionId) + sessionChatWindowSources.delete(normalizedSessionId) } }) sessionChatWindows.set(normalizedSessionId, win) + sessionChatWindowSources.set(normalizedSessionId, normalizedSource) return win } @@ -1071,8 +1101,8 @@ function registerIpcHandlers() { }) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) - ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { - const win = createSessionChatWindow(sessionId) + ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => { + const win = createSessionChatWindow(sessionId, options) return Boolean(win) }) @@ -1410,6 +1440,7 @@ function registerIpcHandlers() { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean }) => { return chatService.getExportSessionStats(sessionIds, options) }) diff --git a/electron/preload.ts b/electron/preload.ts index c173d10..3c51ee3 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -99,8 +99,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), openChatHistoryWindow: (sessionId: string, messageId: number) => ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), - openSessionChatWindow: (sessionId: string) => - ipcRenderer.invoke('window:openSessionChatWindow', sessionId) + openSessionChatWindow: (sessionId: string, options?: { source?: 'chat' | 'export' }) => + ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options) }, // 数据库路径 @@ -174,7 +174,13 @@ contextBridge.exposeInMainWorld('electronAPI', { getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options), getGroupMyMessageCountHint: (chatroomId: string) => ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 4c5e43e..68cb598 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -164,6 +164,7 @@ interface ExportSessionStatsOptions { forceRefresh?: boolean allowStaleCache?: boolean preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean } interface ExportSessionStatsCacheMeta { @@ -5354,6 +5355,7 @@ class ChatService { const forceRefresh = options.forceRefresh === true const allowStaleCache = options.allowStaleCache === true const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true + const cacheOnly = options.cacheOnly === true const normalizedSessionIds = Array.from( new Set( @@ -5377,32 +5379,34 @@ class ChatService { ? this.getGroupMyMessageCountHintEntry(sessionId) : null const cachedResult = this.getSessionStatsCacheEntry(sessionId) - if (!forceRefresh && !preferAccurateSpecialTypes) { - if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { - const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs - if (!stale || allowStaleCache) { - resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) - if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { - resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount - } - cacheMeta[sessionId] = { - updatedAt: cachedResult.entry.updatedAt, - stale, - includeRelations: cachedResult.entry.includeRelations, - source: cachedResult.source - } - if (stale) { - needsRefreshSet.add(sessionId) - } - continue + const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes) + if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { + const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs + if (!stale || allowStaleCache || cacheOnly) { + resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) + if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { + resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount + } + cacheMeta[sessionId] = { + updatedAt: cachedResult.entry.updatedAt, + stale, + includeRelations: cachedResult.entry.includeRelations, + source: cachedResult.source + } + if (stale) { + needsRefreshSet.add(sessionId) } - } - // allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。 - if (allowStaleCache && cachedResult) { - needsRefreshSet.add(sessionId) continue } } + // allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。 + if (canUseCache && allowStaleCache && cachedResult) { + needsRefreshSet.add(sessionId) + continue + } + if (cacheOnly) { + continue + } pendingSessionIds.push(sessionId) } diff --git a/src/App.tsx b/src/App.tsx index 9d040d2..05d1990 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -402,8 +402,10 @@ function App() { // 独立会话聊天窗口(仅显示聊天内容区域) if (isStandaloneChatWindow) { - const sessionId = new URLSearchParams(location.search).get('sessionId') || '' - return + const params = new URLSearchParams(location.search) + const sessionId = params.get('sessionId') || '' + const standaloneSource = params.get('source') + return } // 独立通知窗口 diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 90df9aa..56d7ba5 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -204,6 +204,7 @@ function formatYmdHmDateTime(timestamp?: number): string { interface ChatPageProps { standaloneSessionWindow?: boolean initialSessionId?: string | null + standaloneSource?: string | null } @@ -408,8 +409,10 @@ const SessionItem = React.memo(function SessionItem({ function ChatPage(props: ChatPageProps) { - const { standaloneSessionWindow = false, initialSessionId = null } = props + const { standaloneSessionWindow = false, initialSessionId = null, standaloneSource = null } = props const normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId]) + const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource]) + const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export' const navigate = useNavigate() const { @@ -3863,13 +3866,15 @@ function ChatPage(props: ChatPageProps) { > - + {!shouldHideStandaloneDetailButton && ( + + )} diff --git a/src/pages/ExportPage.tsx b/src/pages/ExportPage.tsx index 01e219f..0615f8b 100644 --- a/src/pages/ExportPage.tsx +++ b/src/pages/ExportPage.tsx @@ -163,6 +163,7 @@ interface TimeRangeDialogDraft { } const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] +const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000 const contentTypeLabels: Record = { text: '聊天文本', voice: '语音', @@ -1307,6 +1308,8 @@ function ExportPage() { const hasBaseConfigReadyRef = useRef(false) const sessionCountRequestIdRef = useRef(0) const activeTabRef = useRef('private') + const detailStatsPriorityRef = useRef(false) + const sessionPreciseRefreshAtRef = useRef>({}) const ensureExportCacheScope = useCallback(async (): Promise => { if (exportCacheScopeReadyRef.current) { @@ -1894,6 +1897,9 @@ function ExportPage() { setIsLoadingSessionCounts(true) try { + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (prioritizedSessionIds.length > 0) { const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1902,6 +1908,9 @@ function ExportPage() { } } + if (detailStatsPriorityRef.current) { + return { ...accumulatedCounts } + } if (remainingSessionIds.length > 0) { const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) if (isStale()) return { ...accumulatedCounts } @@ -1930,6 +1939,7 @@ function ExportPage() { const loadToken = Date.now() sessionLoadTokenRef.current = loadToken sessionsHydratedAtRef.current = 0 + sessionPreciseRefreshAtRef.current = {} setIsLoading(true) setIsSessionEnriching(false) sessionCountRequestIdRef.current += 1 @@ -2027,12 +2037,14 @@ function ExportPage() { setIsSessionEnriching(true) void (async () => { try { + if (detailStatsPriorityRef.current) return let contactMap = { ...cachedContactMap } let avatarEntries = { ...cachedAvatarEntries } let hasFreshNetworkData = false let hasNetworkContactsSnapshot = false if (isStale()) return + if (detailStatsPriorityRef.current) return const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) if (isStale()) return @@ -2091,6 +2103,7 @@ function ExportPage() { if (needsEnrichment.length > 0) { for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { if (isStale()) return + if (detailStatsPriorityRef.current) return const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) if (batch.length === 0) continue try { @@ -3399,6 +3412,11 @@ function ExportPage() { const loadSessionDetail = useCallback(async (sessionId: string) => { const normalizedSessionId = String(sessionId || '').trim() if (!normalizedSessionId) return + const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}` + + detailStatsPriorityRef.current = true + sessionCountRequestIdRef.current += 1 + setIsLoadingSessionCounts(false) const requestSeq = ++detailRequestSeqRef.current const mappedSession = sessionRowByUsername.get(normalizedSessionId) @@ -3510,19 +3528,13 @@ function ExportPage() { } try { - const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ - window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), - window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, allowStaleCache: true } - ) - ]) - - if (requestSeq !== detailRequestSeqRef.current) return - - if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) { - const detail = extraResultSettled.value.detail - if (detail) { + const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId) + void (async () => { + try { + const extraResult = await extraPromise + if (requestSeq !== detailRequestSeqRef.current) return + if (!extraResult.success || !extraResult.detail) return + const detail = extraResult.detail setSessionDetail((prev) => { if (!prev || prev.wxid !== normalizedSessionId) return prev return { @@ -3532,62 +3544,86 @@ function ExportPage() { messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] } }) - } - } - - if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success) { - const metric = statsResultSettled.value.data?.[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = statsResultSettled.value.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } - - setIsRefreshingSessionDetailStats(true) - void (async () => { - try { - // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 - const freshResult = await window.electronAPI.chat.getExportSessionStats( - [normalizedSessionId], - { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } - ) - if (requestSeq !== detailRequestSeqRef.current) return - if (freshResult.success && freshResult.data) { - const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined - const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined - if (metric) { - applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) - } else if (cacheMeta) { - setSessionDetail((prev) => { - if (!prev || prev.wxid !== normalizedSessionId) return prev - return { - ...prev, - statsUpdatedAt: cacheMeta.updatedAt, - statsStale: cacheMeta.stale - } - }) - } - } } catch (error) { - console.error('导出页刷新会话统计失败:', error) + console.error('导出页加载会话详情补充信息失败:', error) } finally { if (requestSeq === detailRequestSeqRef.current) { - setIsRefreshingSessionDetailStats(false) + setIsLoadingSessionDetailExtra(false) } } })() + + let quickMetric: SessionExportMetric | undefined + let quickCacheMeta: SessionExportCacheMeta | undefined + try { + const quickStatsResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, allowStaleCache: true, cacheOnly: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (quickStatsResult.success) { + quickMetric = quickStatsResult.data?.[normalizedSessionId] as SessionExportMetric | undefined + quickCacheMeta = quickStatsResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (quickMetric) { + applySessionDetailStats(normalizedSessionId, quickMetric, quickCacheMeta, false) + } else if (quickCacheMeta) { + const cacheMeta = quickCacheMeta + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页读取会话统计缓存失败:', error) + } + + const lastPreciseAt = sessionPreciseRefreshAtRef.current[preciseCacheKey] || 0 + const hasRecentPrecise = Date.now() - lastPreciseAt <= DETAIL_PRECISE_REFRESH_COOLDOWN_MS + const shouldRunPreciseRefresh = !hasRecentPrecise && (!quickMetric || Boolean(quickCacheMeta?.stale)) + + if (shouldRunPreciseRefresh) { + setIsRefreshingSessionDetailStats(true) + void (async () => { + try { + // 后台精确补算三类重字段(转账/红包/通话),不阻塞首屏基础统计显示。 + const freshResult = await window.electronAPI.chat.getExportSessionStats( + [normalizedSessionId], + { includeRelations: false, forceRefresh: true, preferAccurateSpecialTypes: true } + ) + if (requestSeq !== detailRequestSeqRef.current) return + if (freshResult.success && freshResult.data) { + const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined + const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined + if (metric) { + applySessionDetailStats(normalizedSessionId, metric, cacheMeta, false) + sessionPreciseRefreshAtRef.current[preciseCacheKey] = Date.now() + } else if (cacheMeta) { + setSessionDetail((prev) => { + if (!prev || prev.wxid !== normalizedSessionId) return prev + return { + ...prev, + statsUpdatedAt: cacheMeta.updatedAt, + statsStale: cacheMeta.stale + } + }) + } + } + } catch (error) { + console.error('导出页刷新会话统计失败:', error) + } finally { + if (requestSeq === detailRequestSeqRef.current) { + setIsRefreshingSessionDetailStats(false) + } + } + })() + } } catch (error) { console.error('导出页加载会话详情补充统计失败:', error) - } finally { if (requestSeq === detailRequestSeqRef.current) { setIsLoadingSessionDetailExtra(false) } @@ -3627,6 +3663,7 @@ function ExportPage() { const closeSessionDetailPanel = useCallback(() => { detailRequestSeqRef.current += 1 + detailStatsPriorityRef.current = false setShowSessionDetailPanel(false) setIsLoadingSessionDetail(false) setIsLoadingSessionDetailExtra(false) @@ -3636,6 +3673,7 @@ function ExportPage() { const openSessionDetail = useCallback((sessionId: string) => { if (!sessionId) return + detailStatsPriorityRef.current = true setShowSessionDetailPanel(true) void loadSessionDetail(sessionId) }, [loadSessionDetail]) @@ -3827,7 +3865,7 @@ function ExportPage() { title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'} onClick={() => { if (!canExport) return - void window.electronAPI.window.openSessionChatWindow(contact.username) + void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' }) }} > diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index a3abc38..079d30f 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -1,5 +1,9 @@ import type { ChatSession, Message, Contact, ContactInfo } from './models' +export interface SessionChatWindowOpenOptions { + source?: 'chat' | 'export' +} + export interface ElectronAPI { window: { minimize: () => void @@ -13,7 +17,7 @@ export interface ElectronAPI { resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise openChatHistoryWindow: (sessionId: string, messageId: number) => Promise - openSessionChatWindow: (sessionId: string) => Promise + openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise } config: { get: (key: string) => Promise @@ -250,7 +254,13 @@ export interface ElectronAPI { }> getExportSessionStats: ( sessionIds: string[], - options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } + options?: { + includeRelations?: boolean + forceRefresh?: boolean + allowStaleCache?: boolean + preferAccurateSpecialTypes?: boolean + cacheOnly?: boolean + } ) => Promise<{ success: boolean data?: Record