perf(export): further optimize detail loading and prioritize session stats

This commit is contained in:
aits2026
2026-03-05 16:05:58 +08:00
parent 2a9f0f24fd
commit a5358b82f6
7 changed files with 205 additions and 109 deletions

View File

@@ -87,6 +87,7 @@ let onboardingWindow: BrowserWindow | null = null
// Splash 启动窗口 // Splash 启动窗口
let splashWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null
const sessionChatWindows = new Map<string, BrowserWindow>() const sessionChatWindows = new Map<string, BrowserWindow>()
const sessionChatWindowSources = new Map<string, 'chat' | 'export'>()
const keyService = new KeyService() const keyService = new KeyService()
let mainWindowReady = false let mainWindowReady = false
@@ -123,6 +124,32 @@ interface AnnualReportYearsTaskState {
updatedAt: number 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<string, AnnualReportYearsTaskState>() const annualReportYearsLoadTasks = new Map<string, AnnualReportYearsTaskState>()
const annualReportYearsTaskByCacheKey = new Map<string, string>() const annualReportYearsTaskByCacheKey = new Map<string, string>()
const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>() const annualReportYearsSnapshotCache = new Map<string, { snapshot: AnnualReportYearsProgressPayload; updatedAt: number; taskId: string }>()
@@ -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() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return null if (!normalizedSessionId) return null
const normalizedSource = normalizeSessionChatWindowSource(options?.source)
const existing = sessionChatWindows.get(normalizedSessionId) const existing = sessionChatWindows.get(normalizedSessionId)
if (existing && !existing.isDestroyed()) { if (existing && !existing.isDestroyed()) {
const trackedSource = sessionChatWindowSources.get(normalizedSessionId) || 'chat'
if (trackedSource !== normalizedSource) {
loadSessionChatWindowContent(existing, normalizedSessionId, normalizedSource)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
}
if (existing.isMinimized()) { if (existing.isMinimized()) {
existing.restore() existing.restore()
} }
@@ -730,10 +763,9 @@ function createSessionChatWindow(sessionId: string) {
autoHideMenuBar: true autoHideMenuBar: true
}) })
const sessionParam = `sessionId=${encodeURIComponent(normalizedSessionId)}` loadSessionChatWindowContent(win, normalizedSessionId, normalizedSource)
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/chat-window?${sessionParam}`)
if (process.env.VITE_DEV_SERVER_URL) {
win.webContents.on('before-input-event', (event, input) => { win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) { if (win.webContents.isDevToolsOpened()) {
@@ -744,10 +776,6 @@ function createSessionChatWindow(sessionId: string) {
event.preventDefault() event.preventDefault()
} }
}) })
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/chat-window?${sessionParam}`
})
} }
win.once('ready-to-show', () => { win.once('ready-to-show', () => {
@@ -759,10 +787,12 @@ function createSessionChatWindow(sessionId: string) {
const tracked = sessionChatWindows.get(normalizedSessionId) const tracked = sessionChatWindows.get(normalizedSessionId)
if (tracked === win) { if (tracked === win) {
sessionChatWindows.delete(normalizedSessionId) sessionChatWindows.delete(normalizedSessionId)
sessionChatWindowSources.delete(normalizedSessionId)
} }
}) })
sessionChatWindows.set(normalizedSessionId, win) sessionChatWindows.set(normalizedSessionId, win)
sessionChatWindowSources.set(normalizedSessionId, normalizedSource)
return win return win
} }
@@ -1071,8 +1101,8 @@ function registerIpcHandlers() {
}) })
// 打开会话聊天窗口(同会话仅保留一个窗口并聚焦) // 打开会话聊天窗口(同会话仅保留一个窗口并聚焦)
ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string) => { ipcMain.handle('window:openSessionChatWindow', (_, sessionId: string, options?: OpenSessionChatWindowOptions) => {
const win = createSessionChatWindow(sessionId) const win = createSessionChatWindow(sessionId, options)
return Boolean(win) return Boolean(win)
}) })
@@ -1410,6 +1440,7 @@ function registerIpcHandlers() {
forceRefresh?: boolean forceRefresh?: boolean
allowStaleCache?: boolean allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}) => { }) => {
return chatService.getExportSessionStats(sessionIds, options) return chatService.getExportSessionStats(sessionIds, options)
}) })

View File

@@ -99,8 +99,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath), ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId), ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId),
openSessionChatWindow: (sessionId: string) => openSessionChatWindow: (sessionId: string, options?: { source?: 'chat' | 'export' }) =>
ipcRenderer.invoke('window:openSessionChatWindow', sessionId) ipcRenderer.invoke('window:openSessionChatWindow', sessionId, options)
}, },
// 数据库路径 // 数据库路径
@@ -174,7 +174,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId), getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
getExportSessionStats: ( getExportSessionStats: (
sessionIds: string[], 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), ) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
getGroupMyMessageCountHint: (chatroomId: string) => getGroupMyMessageCountHint: (chatroomId: string) =>
ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId), ipcRenderer.invoke('chat:getGroupMyMessageCountHint', chatroomId),

View File

@@ -164,6 +164,7 @@ interface ExportSessionStatsOptions {
forceRefresh?: boolean forceRefresh?: boolean
allowStaleCache?: boolean allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
} }
interface ExportSessionStatsCacheMeta { interface ExportSessionStatsCacheMeta {
@@ -5354,6 +5355,7 @@ class ChatService {
const forceRefresh = options.forceRefresh === true const forceRefresh = options.forceRefresh === true
const allowStaleCache = options.allowStaleCache === true const allowStaleCache = options.allowStaleCache === true
const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true const preferAccurateSpecialTypes = options.preferAccurateSpecialTypes === true
const cacheOnly = options.cacheOnly === true
const normalizedSessionIds = Array.from( const normalizedSessionIds = Array.from(
new Set( new Set(
@@ -5377,32 +5379,34 @@ class ChatService {
? this.getGroupMyMessageCountHintEntry(sessionId) ? this.getGroupMyMessageCountHintEntry(sessionId)
: null : null
const cachedResult = this.getSessionStatsCacheEntry(sessionId) const cachedResult = this.getSessionStatsCacheEntry(sessionId)
if (!forceRefresh && !preferAccurateSpecialTypes) { const canUseCache = cacheOnly || (!forceRefresh && !preferAccurateSpecialTypes)
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) { if (canUseCache && cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
if (!stale || allowStaleCache) { if (!stale || allowStaleCache || cacheOnly) {
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats) resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) { if (groupMyMessagesHint && Number.isFinite(groupMyMessagesHint.entry.messageCount)) {
resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount resultMap[sessionId].groupMyMessages = groupMyMessagesHint.entry.messageCount
} }
cacheMeta[sessionId] = { cacheMeta[sessionId] = {
updatedAt: cachedResult.entry.updatedAt, updatedAt: cachedResult.entry.updatedAt,
stale, stale,
includeRelations: cachedResult.entry.includeRelations, includeRelations: cachedResult.entry.includeRelations,
source: cachedResult.source source: cachedResult.source
} }
if (stale) { if (stale) {
needsRefreshSet.add(sessionId) needsRefreshSet.add(sessionId)
}
continue
} }
}
// allowStaleCache 仅对“已有缓存”生效;无缓存会话仍需进入计算流程。
if (allowStaleCache && cachedResult) {
needsRefreshSet.add(sessionId)
continue continue
} }
} }
// allowStaleCache/cacheOnly 仅对“已有缓存”生效;无缓存会话不会直接算重查询。
if (canUseCache && allowStaleCache && cachedResult) {
needsRefreshSet.add(sessionId)
continue
}
if (cacheOnly) {
continue
}
pendingSessionIds.push(sessionId) pendingSessionIds.push(sessionId)
} }

View File

@@ -402,8 +402,10 @@ function App() {
// 独立会话聊天窗口(仅显示聊天内容区域) // 独立会话聊天窗口(仅显示聊天内容区域)
if (isStandaloneChatWindow) { if (isStandaloneChatWindow) {
const sessionId = new URLSearchParams(location.search).get('sessionId') || '' const params = new URLSearchParams(location.search)
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} /> const sessionId = params.get('sessionId') || ''
const standaloneSource = params.get('source')
return <ChatPage standaloneSessionWindow initialSessionId={sessionId} standaloneSource={standaloneSource} />
} }
// 独立通知窗口 // 独立通知窗口

View File

@@ -204,6 +204,7 @@ function formatYmdHmDateTime(timestamp?: number): string {
interface ChatPageProps { interface ChatPageProps {
standaloneSessionWindow?: boolean standaloneSessionWindow?: boolean
initialSessionId?: string | null initialSessionId?: string | null
standaloneSource?: string | null
} }
@@ -408,8 +409,10 @@ const SessionItem = React.memo(function SessionItem({
function ChatPage(props: ChatPageProps) { 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 normalizedInitialSessionId = useMemo(() => String(initialSessionId || '').trim(), [initialSessionId])
const normalizedStandaloneSource = useMemo(() => String(standaloneSource || '').trim().toLowerCase(), [standaloneSource])
const shouldHideStandaloneDetailButton = standaloneSessionWindow && normalizedStandaloneSource === 'export'
const navigate = useNavigate() const navigate = useNavigate()
const { const {
@@ -3863,13 +3866,15 @@ function ChatPage(props: ChatPageProps) {
> >
<RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} /> <RefreshCw size={18} className={isRefreshingMessages ? 'spin' : ''} />
</button> </button>
<button {!shouldHideStandaloneDetailButton && (
className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`} <button
onClick={toggleDetailPanel} className={`icon-btn detail-btn ${showDetailPanel ? 'active' : ''}`}
title="会话详情" onClick={toggleDetailPanel}
> title="会话详情"
<Info size={18} /> >
</button> <Info size={18} />
</button>
)}
</div> </div>
</div> </div>

View File

@@ -163,6 +163,7 @@ interface TimeRangeDialogDraft {
} }
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content'] const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
const DETAIL_PRECISE_REFRESH_COOLDOWN_MS = 10 * 60 * 1000
const contentTypeLabels: Record<ContentType, string> = { const contentTypeLabels: Record<ContentType, string> = {
text: '聊天文本', text: '聊天文本',
voice: '语音', voice: '语音',
@@ -1307,6 +1308,8 @@ function ExportPage() {
const hasBaseConfigReadyRef = useRef(false) const hasBaseConfigReadyRef = useRef(false)
const sessionCountRequestIdRef = useRef(0) const sessionCountRequestIdRef = useRef(0)
const activeTabRef = useRef<ConversationTab>('private') const activeTabRef = useRef<ConversationTab>('private')
const detailStatsPriorityRef = useRef(false)
const sessionPreciseRefreshAtRef = useRef<Record<string, number>>({})
const ensureExportCacheScope = useCallback(async (): Promise<string> => { const ensureExportCacheScope = useCallback(async (): Promise<string> => {
if (exportCacheScopeReadyRef.current) { if (exportCacheScopeReadyRef.current) {
@@ -1894,6 +1897,9 @@ function ExportPage() {
setIsLoadingSessionCounts(true) setIsLoadingSessionCounts(true)
try { try {
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (prioritizedSessionIds.length > 0) { if (prioritizedSessionIds.length > 0) {
const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds) const priorityResult = await window.electronAPI.chat.getSessionMessageCounts(prioritizedSessionIds)
if (isStale()) return { ...accumulatedCounts } if (isStale()) return { ...accumulatedCounts }
@@ -1902,6 +1908,9 @@ function ExportPage() {
} }
} }
if (detailStatsPriorityRef.current) {
return { ...accumulatedCounts }
}
if (remainingSessionIds.length > 0) { if (remainingSessionIds.length > 0) {
const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds) const remainingResult = await window.electronAPI.chat.getSessionMessageCounts(remainingSessionIds)
if (isStale()) return { ...accumulatedCounts } if (isStale()) return { ...accumulatedCounts }
@@ -1930,6 +1939,7 @@ function ExportPage() {
const loadToken = Date.now() const loadToken = Date.now()
sessionLoadTokenRef.current = loadToken sessionLoadTokenRef.current = loadToken
sessionsHydratedAtRef.current = 0 sessionsHydratedAtRef.current = 0
sessionPreciseRefreshAtRef.current = {}
setIsLoading(true) setIsLoading(true)
setIsSessionEnriching(false) setIsSessionEnriching(false)
sessionCountRequestIdRef.current += 1 sessionCountRequestIdRef.current += 1
@@ -2027,12 +2037,14 @@ function ExportPage() {
setIsSessionEnriching(true) setIsSessionEnriching(true)
void (async () => { void (async () => {
try { try {
if (detailStatsPriorityRef.current) return
let contactMap = { ...cachedContactMap } let contactMap = { ...cachedContactMap }
let avatarEntries = { ...cachedAvatarEntries } let avatarEntries = { ...cachedAvatarEntries }
let hasFreshNetworkData = false let hasFreshNetworkData = false
let hasNetworkContactsSnapshot = false let hasNetworkContactsSnapshot = false
if (isStale()) return if (isStale()) return
if (detailStatsPriorityRef.current) return
const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS) const contactsResult = await withTimeout(window.electronAPI.chat.getContacts(), CONTACT_ENRICH_TIMEOUT_MS)
if (isStale()) return if (isStale()) return
@@ -2091,6 +2103,7 @@ function ExportPage() {
if (needsEnrichment.length > 0) { if (needsEnrichment.length > 0) {
for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) { for (let i = 0; i < needsEnrichment.length; i += EXPORT_AVATAR_ENRICH_BATCH_SIZE) {
if (isStale()) return if (isStale()) return
if (detailStatsPriorityRef.current) return
const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE) const batch = needsEnrichment.slice(i, i + EXPORT_AVATAR_ENRICH_BATCH_SIZE)
if (batch.length === 0) continue if (batch.length === 0) continue
try { try {
@@ -3399,6 +3412,11 @@ function ExportPage() {
const loadSessionDetail = useCallback(async (sessionId: string) => { const loadSessionDetail = useCallback(async (sessionId: string) => {
const normalizedSessionId = String(sessionId || '').trim() const normalizedSessionId = String(sessionId || '').trim()
if (!normalizedSessionId) return if (!normalizedSessionId) return
const preciseCacheKey = `${exportCacheScopeRef.current}::${normalizedSessionId}`
detailStatsPriorityRef.current = true
sessionCountRequestIdRef.current += 1
setIsLoadingSessionCounts(false)
const requestSeq = ++detailRequestSeqRef.current const requestSeq = ++detailRequestSeqRef.current
const mappedSession = sessionRowByUsername.get(normalizedSessionId) const mappedSession = sessionRowByUsername.get(normalizedSessionId)
@@ -3510,19 +3528,13 @@ function ExportPage() {
} }
try { try {
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([ const extraPromise = window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId)
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId), void (async () => {
window.electronAPI.chat.getExportSessionStats( try {
[normalizedSessionId], const extraResult = await extraPromise
{ includeRelations: false, allowStaleCache: true } if (requestSeq !== detailRequestSeqRef.current) return
) if (!extraResult.success || !extraResult.detail) return
]) const detail = extraResult.detail
if (requestSeq !== detailRequestSeqRef.current) return
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success) {
const detail = extraResultSettled.value.detail
if (detail) {
setSessionDetail((prev) => { setSessionDetail((prev) => {
if (!prev || prev.wxid !== normalizedSessionId) return prev if (!prev || prev.wxid !== normalizedSessionId) return prev
return { return {
@@ -3532,62 +3544,86 @@ function ExportPage() {
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : [] 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) { } catch (error) {
console.error('导出页刷新会话统计失败:', error) console.error('导出页加载会话详情补充信息失败:', error)
} finally { } finally {
if (requestSeq === detailRequestSeqRef.current) { 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) { } catch (error) {
console.error('导出页加载会话详情补充统计失败:', error) console.error('导出页加载会话详情补充统计失败:', error)
} finally {
if (requestSeq === detailRequestSeqRef.current) { if (requestSeq === detailRequestSeqRef.current) {
setIsLoadingSessionDetailExtra(false) setIsLoadingSessionDetailExtra(false)
} }
@@ -3627,6 +3663,7 @@ function ExportPage() {
const closeSessionDetailPanel = useCallback(() => { const closeSessionDetailPanel = useCallback(() => {
detailRequestSeqRef.current += 1 detailRequestSeqRef.current += 1
detailStatsPriorityRef.current = false
setShowSessionDetailPanel(false) setShowSessionDetailPanel(false)
setIsLoadingSessionDetail(false) setIsLoadingSessionDetail(false)
setIsLoadingSessionDetailExtra(false) setIsLoadingSessionDetailExtra(false)
@@ -3636,6 +3673,7 @@ function ExportPage() {
const openSessionDetail = useCallback((sessionId: string) => { const openSessionDetail = useCallback((sessionId: string) => {
if (!sessionId) return if (!sessionId) return
detailStatsPriorityRef.current = true
setShowSessionDetailPanel(true) setShowSessionDetailPanel(true)
void loadSessionDetail(sessionId) void loadSessionDetail(sessionId)
}, [loadSessionDetail]) }, [loadSessionDetail])
@@ -3827,7 +3865,7 @@ function ExportPage() {
title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'} title={canExport ? '在新窗口打开该会话' : '该联系人暂无会话记录'}
onClick={() => { onClick={() => {
if (!canExport) return if (!canExport) return
void window.electronAPI.window.openSessionChatWindow(contact.username) void window.electronAPI.window.openSessionChatWindow(contact.username, { source: 'export' })
}} }}
> >
<ExternalLink size={13} /> <ExternalLink size={13} />

View File

@@ -1,5 +1,9 @@
import type { ChatSession, Message, Contact, ContactInfo } from './models' import type { ChatSession, Message, Contact, ContactInfo } from './models'
export interface SessionChatWindowOpenOptions {
source?: 'chat' | 'export'
}
export interface ElectronAPI { export interface ElectronAPI {
window: { window: {
minimize: () => void minimize: () => void
@@ -13,7 +17,7 @@ export interface ElectronAPI {
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void> openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
openSessionChatWindow: (sessionId: string) => Promise<boolean> openSessionChatWindow: (sessionId: string, options?: SessionChatWindowOpenOptions) => Promise<boolean>
} }
config: { config: {
get: (key: string) => Promise<unknown> get: (key: string) => Promise<unknown>
@@ -250,7 +254,13 @@ export interface ElectronAPI {
}> }>
getExportSessionStats: ( getExportSessionStats: (
sessionIds: string[], sessionIds: string[],
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean; preferAccurateSpecialTypes?: boolean } options?: {
includeRelations?: boolean
forceRefresh?: boolean
allowStaleCache?: boolean
preferAccurateSpecialTypes?: boolean
cacheOnly?: boolean
}
) => Promise<{ ) => Promise<{
success: boolean success: boolean
data?: Record<string, { data?: Record<string, {