mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat(export): optimize batch export flow and unify session detail typing
This commit is contained in:
@@ -1024,8 +1024,12 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getSessionDetailExtra(sessionId)
|
return chatService.getSessionDetailExtra(sessionId)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[]) => {
|
ipcMain.handle('chat:getExportSessionStats', async (_, sessionIds: string[], options?: {
|
||||||
return chatService.getExportSessionStats(sessionIds)
|
includeRelations?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
allowStaleCache?: boolean
|
||||||
|
}) => {
|
||||||
|
return chatService.getExportSessionStats(sessionIds, options)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
|
ipcMain.handle('chat:getImageData', async (_, sessionId: string, msgId: string) => {
|
||||||
@@ -1394,6 +1398,10 @@ function registerIpcHandlers() {
|
|||||||
return groupAnalyticsService.getGroupMembers(chatroomId)
|
return groupAnalyticsService.getGroupMembers(chatroomId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('groupAnalytics:getGroupMembersPanelData', async (_, chatroomId: string, forceRefresh?: boolean) => {
|
||||||
|
return groupAnalyticsService.getGroupMembersPanelData(chatroomId, forceRefresh)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
|
ipcMain.handle('groupAnalytics:getGroupMessageRanking', async (_, chatroomId: string, limit?: number, startTime?: number, endTime?: number) => {
|
||||||
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
|
return groupAnalyticsService.getGroupMessageRanking(chatroomId, limit, startTime, endTime)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -157,7 +157,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
getSessionDetailFast: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailFast', sessionId),
|
||||||
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
getSessionDetailExtra: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetailExtra', sessionId),
|
||||||
getExportSessionStats: (sessionIds: string[]) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds),
|
getExportSessionStats: (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean }
|
||||||
|
) => ipcRenderer.invoke('chat:getExportSessionStats', sessionIds, options),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
|
||||||
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
|
||||||
@@ -234,6 +237,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
groupAnalytics: {
|
groupAnalytics: {
|
||||||
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
getGroupChats: () => ipcRenderer.invoke('groupAnalytics:getGroupChats'),
|
||||||
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
|
||||||
|
getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) =>
|
||||||
|
ipcRenderer.invoke('groupAnalytics:getGroupMembersPanelData', chatroomId, forceRefresh),
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
|
||||||
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
|
||||||
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ConfigService } from './config'
|
|||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
import { MessageCacheService } from './messageCacheService'
|
import { MessageCacheService } from './messageCacheService'
|
||||||
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||||
|
import { SessionStatsCacheService, SessionStatsCacheEntry, SessionStatsCacheStats } from './sessionStatsCacheService'
|
||||||
import { voiceTranscribeService } from './voiceTranscribeService'
|
import { voiceTranscribeService } from './voiceTranscribeService'
|
||||||
import { LRUCache } from '../utils/LRUCache.js'
|
import { LRUCache } from '../utils/LRUCache.js'
|
||||||
|
|
||||||
@@ -152,6 +153,19 @@ interface ExportSessionStats {
|
|||||||
groupMutualFriends?: number
|
groupMutualFriends?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExportSessionStatsOptions {
|
||||||
|
includeRelations?: boolean
|
||||||
|
forceRefresh?: boolean
|
||||||
|
allowStaleCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportSessionStatsCacheMeta {
|
||||||
|
updatedAt: number
|
||||||
|
stale: boolean
|
||||||
|
includeRelations: boolean
|
||||||
|
source: 'memory' | 'disk' | 'fresh'
|
||||||
|
}
|
||||||
|
|
||||||
interface ExportTabCounts {
|
interface ExportTabCounts {
|
||||||
private: number
|
private: number
|
||||||
group: number
|
group: number
|
||||||
@@ -194,6 +208,7 @@ class ChatService {
|
|||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
private hardlinkCache = new Map<string, HardlinkState>()
|
||||||
private readonly contactCacheService: ContactCacheService
|
private readonly contactCacheService: ContactCacheService
|
||||||
private readonly messageCacheService: MessageCacheService
|
private readonly messageCacheService: MessageCacheService
|
||||||
|
private readonly sessionStatsCacheService: SessionStatsCacheService
|
||||||
private voiceWavCache: LRUCache<string, Buffer>
|
private voiceWavCache: LRUCache<string, Buffer>
|
||||||
private voiceTranscriptCache: LRUCache<string, string>
|
private voiceTranscriptCache: LRUCache<string, string>
|
||||||
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
|
||||||
@@ -225,6 +240,13 @@ class ChatService {
|
|||||||
private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000
|
private readonly sessionDetailExtraCacheTtlMs = 5 * 60 * 1000
|
||||||
private sessionStatusCache = new Map<string, { isFolded?: boolean; isMuted?: boolean; updatedAt: number }>()
|
private sessionStatusCache = new Map<string, { isFolded?: boolean; isMuted?: boolean; updatedAt: number }>()
|
||||||
private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000
|
private readonly sessionStatusCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private sessionStatsCacheScope = ''
|
||||||
|
private sessionStatsMemoryCache = new Map<string, SessionStatsCacheEntry>()
|
||||||
|
private sessionStatsPendingBasic = new Map<string, Promise<ExportSessionStats>>()
|
||||||
|
private sessionStatsPendingFull = new Map<string, Promise<ExportSessionStats>>()
|
||||||
|
private allGroupSessionIdsCache: { ids: string[]; updatedAt: number } | null = null
|
||||||
|
private readonly sessionStatsCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly allGroupSessionIdsCacheTtlMs = 5 * 60 * 1000
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -232,6 +254,7 @@ class ChatService {
|
|||||||
const persisted = this.contactCacheService.getAllEntries()
|
const persisted = this.contactCacheService.getAllEntries()
|
||||||
this.avatarCache = new Map(Object.entries(persisted))
|
this.avatarCache = new Map(Object.entries(persisted))
|
||||||
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
|
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
|
||||||
|
this.sessionStatsCacheService = new SessionStatsCacheService(this.configService.getCacheBasePath())
|
||||||
// 初始化LRU缓存,限制大小防止内存泄漏
|
// 初始化LRU缓存,限制大小防止内存泄漏
|
||||||
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
|
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
|
||||||
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
|
||||||
@@ -319,6 +342,7 @@ class ChatService {
|
|||||||
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
|
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
|
||||||
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
|
||||||
wcdbService.setMonitor((type, json) => {
|
wcdbService.setMonitor((type, json) => {
|
||||||
|
this.handleSessionStatsMonitorChange(type, json)
|
||||||
// 广播给所有渲染进程窗口
|
// 广播给所有渲染进程窗口
|
||||||
BrowserWindow.getAllWindows().forEach((win) => {
|
BrowserWindow.getAllWindows().forEach((win) => {
|
||||||
if (!win.isDestroyed()) {
|
if (!win.isDestroyed()) {
|
||||||
@@ -1558,13 +1582,212 @@ class ChatService {
|
|||||||
const dbPath = String(this.configService.get('dbPath') || '')
|
const dbPath = String(this.configService.get('dbPath') || '')
|
||||||
const myWxid = String(this.configService.get('myWxid') || '')
|
const myWxid = String(this.configService.get('myWxid') || '')
|
||||||
const scope = `${dbPath}::${myWxid}`
|
const scope = `${dbPath}::${myWxid}`
|
||||||
if (scope === this.sessionMessageCountCacheScope) return
|
if (scope === this.sessionMessageCountCacheScope) {
|
||||||
|
this.refreshSessionStatsCacheScope(scope)
|
||||||
|
return
|
||||||
|
}
|
||||||
this.sessionMessageCountCacheScope = scope
|
this.sessionMessageCountCacheScope = scope
|
||||||
this.sessionMessageCountCache.clear()
|
this.sessionMessageCountCache.clear()
|
||||||
this.sessionMessageCountHintCache.clear()
|
this.sessionMessageCountHintCache.clear()
|
||||||
this.sessionDetailFastCache.clear()
|
this.sessionDetailFastCache.clear()
|
||||||
this.sessionDetailExtraCache.clear()
|
this.sessionDetailExtraCache.clear()
|
||||||
this.sessionStatusCache.clear()
|
this.sessionStatusCache.clear()
|
||||||
|
this.refreshSessionStatsCacheScope(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshSessionStatsCacheScope(scope: string): void {
|
||||||
|
if (scope === this.sessionStatsCacheScope) return
|
||||||
|
this.sessionStatsCacheScope = scope
|
||||||
|
this.sessionStatsMemoryCache.clear()
|
||||||
|
this.sessionStatsPendingBasic.clear()
|
||||||
|
this.sessionStatsPendingFull.clear()
|
||||||
|
this.allGroupSessionIdsCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildScopedSessionStatsKey(sessionId: string): string {
|
||||||
|
return `${this.sessionStatsCacheScope}::${sessionId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSessionStatsCacheStats(stats: ExportSessionStats): SessionStatsCacheStats {
|
||||||
|
const normalized: SessionStatsCacheStats = {
|
||||||
|
totalMessages: Number.isFinite(stats.totalMessages) ? Math.max(0, Math.floor(stats.totalMessages)) : 0,
|
||||||
|
voiceMessages: Number.isFinite(stats.voiceMessages) ? Math.max(0, Math.floor(stats.voiceMessages)) : 0,
|
||||||
|
imageMessages: Number.isFinite(stats.imageMessages) ? Math.max(0, Math.floor(stats.imageMessages)) : 0,
|
||||||
|
videoMessages: Number.isFinite(stats.videoMessages) ? Math.max(0, Math.floor(stats.videoMessages)) : 0,
|
||||||
|
emojiMessages: Number.isFinite(stats.emojiMessages) ? Math.max(0, Math.floor(stats.emojiMessages)) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(stats.firstTimestamp)) normalized.firstTimestamp = Math.max(0, Math.floor(stats.firstTimestamp as number))
|
||||||
|
if (Number.isFinite(stats.lastTimestamp)) normalized.lastTimestamp = Math.max(0, Math.floor(stats.lastTimestamp as number))
|
||||||
|
if (Number.isFinite(stats.privateMutualGroups)) normalized.privateMutualGroups = Math.max(0, Math.floor(stats.privateMutualGroups as number))
|
||||||
|
if (Number.isFinite(stats.groupMemberCount)) normalized.groupMemberCount = Math.max(0, Math.floor(stats.groupMemberCount as number))
|
||||||
|
if (Number.isFinite(stats.groupMyMessages)) normalized.groupMyMessages = Math.max(0, Math.floor(stats.groupMyMessages as number))
|
||||||
|
if (Number.isFinite(stats.groupActiveSpeakers)) normalized.groupActiveSpeakers = Math.max(0, Math.floor(stats.groupActiveSpeakers as number))
|
||||||
|
if (Number.isFinite(stats.groupMutualFriends)) normalized.groupMutualFriends = Math.max(0, Math.floor(stats.groupMutualFriends as number))
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromSessionStatsCacheStats(stats: SessionStatsCacheStats): ExportSessionStats {
|
||||||
|
return {
|
||||||
|
totalMessages: stats.totalMessages,
|
||||||
|
voiceMessages: stats.voiceMessages,
|
||||||
|
imageMessages: stats.imageMessages,
|
||||||
|
videoMessages: stats.videoMessages,
|
||||||
|
emojiMessages: stats.emojiMessages,
|
||||||
|
firstTimestamp: stats.firstTimestamp,
|
||||||
|
lastTimestamp: stats.lastTimestamp,
|
||||||
|
privateMutualGroups: stats.privateMutualGroups,
|
||||||
|
groupMemberCount: stats.groupMemberCount,
|
||||||
|
groupMyMessages: stats.groupMyMessages,
|
||||||
|
groupActiveSpeakers: stats.groupActiveSpeakers,
|
||||||
|
groupMutualFriends: stats.groupMutualFriends
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private supportsRequestedRelation(entry: SessionStatsCacheEntry, includeRelations: boolean): boolean {
|
||||||
|
if (!includeRelations) return true
|
||||||
|
return entry.includeRelations
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSessionStatsCacheEntry(sessionId: string): { entry: SessionStatsCacheEntry; source: 'memory' | 'disk' } | null {
|
||||||
|
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
|
||||||
|
const inMemory = this.sessionStatsMemoryCache.get(scopedKey)
|
||||||
|
if (inMemory) {
|
||||||
|
return { entry: inMemory, source: 'memory' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = this.sessionStatsCacheService.get(this.sessionStatsCacheScope, sessionId)
|
||||||
|
if (!persisted) return null
|
||||||
|
this.sessionStatsMemoryCache.set(scopedKey, persisted)
|
||||||
|
return { entry: persisted, source: 'disk' }
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSessionStatsCacheEntry(sessionId: string, stats: ExportSessionStats, includeRelations: boolean): number {
|
||||||
|
const updatedAt = Date.now()
|
||||||
|
const entry: SessionStatsCacheEntry = {
|
||||||
|
updatedAt,
|
||||||
|
includeRelations,
|
||||||
|
stats: this.toSessionStatsCacheStats(stats)
|
||||||
|
}
|
||||||
|
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
|
||||||
|
this.sessionStatsMemoryCache.set(scopedKey, entry)
|
||||||
|
this.sessionStatsCacheService.set(this.sessionStatsCacheScope, sessionId, entry)
|
||||||
|
return updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteSessionStatsCacheEntry(sessionId: string): void {
|
||||||
|
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
|
||||||
|
this.sessionStatsMemoryCache.delete(scopedKey)
|
||||||
|
this.sessionStatsPendingBasic.delete(scopedKey)
|
||||||
|
this.sessionStatsPendingFull.delete(scopedKey)
|
||||||
|
this.sessionStatsCacheService.delete(this.sessionStatsCacheScope, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSessionStatsCacheForScope(): void {
|
||||||
|
this.sessionStatsMemoryCache.clear()
|
||||||
|
this.sessionStatsPendingBasic.clear()
|
||||||
|
this.sessionStatsPendingFull.clear()
|
||||||
|
this.allGroupSessionIdsCache = null
|
||||||
|
this.sessionStatsCacheService.clearScope(this.sessionStatsCacheScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectSessionIdsFromPayload(payload: unknown): Set<string> {
|
||||||
|
const ids = new Set<string>()
|
||||||
|
const walk = (value: unknown, keyHint?: string) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) walk(item, keyHint)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
walk(v, k)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') return
|
||||||
|
const normalized = value.trim()
|
||||||
|
if (!normalized) return
|
||||||
|
const lowerKey = String(keyHint || '').toLowerCase()
|
||||||
|
const keyLooksLikeSession = (
|
||||||
|
lowerKey.includes('session') ||
|
||||||
|
lowerKey.includes('talker') ||
|
||||||
|
lowerKey.includes('username') ||
|
||||||
|
lowerKey.includes('chatroom')
|
||||||
|
)
|
||||||
|
if (!keyLooksLikeSession && !normalized.includes('@chatroom')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids.add(normalized)
|
||||||
|
}
|
||||||
|
walk(payload)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSessionStatsMonitorChange(type: string, json: string): void {
|
||||||
|
this.refreshSessionMessageCountCacheScope()
|
||||||
|
if (!this.sessionStatsCacheScope) return
|
||||||
|
|
||||||
|
const normalizedType = String(type || '').toLowerCase()
|
||||||
|
const maybeJson = String(json || '').trim()
|
||||||
|
let ids = new Set<string>()
|
||||||
|
if (maybeJson) {
|
||||||
|
try {
|
||||||
|
ids = this.collectSessionIdsFromPayload(JSON.parse(maybeJson))
|
||||||
|
} catch {
|
||||||
|
ids = this.collectSessionIdsFromPayload(maybeJson)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ids.size > 0) {
|
||||||
|
ids.forEach((sessionId) => this.deleteSessionStatsCacheEntry(sessionId))
|
||||||
|
if (Array.from(ids).some((id) => id.includes('@chatroom'))) {
|
||||||
|
this.allGroupSessionIdsCache = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无法定位具体会话时,保守地仅在消息/群成员相关变更时清空当前 scope,避免展示过旧统计。
|
||||||
|
if (
|
||||||
|
normalizedType.includes('message') ||
|
||||||
|
normalizedType.includes('session') ||
|
||||||
|
normalizedType.includes('group') ||
|
||||||
|
normalizedType.includes('member') ||
|
||||||
|
normalizedType.includes('contact')
|
||||||
|
) {
|
||||||
|
this.clearSessionStatsCacheForScope()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async listAllGroupSessionIds(): Promise<string[]> {
|
||||||
|
const now = Date.now()
|
||||||
|
if (
|
||||||
|
this.allGroupSessionIdsCache &&
|
||||||
|
now - this.allGroupSessionIdsCache.updatedAt <= this.allGroupSessionIdsCacheTtlMs
|
||||||
|
) {
|
||||||
|
return this.allGroupSessionIdsCache.ids
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await wcdbService.getSessions()
|
||||||
|
if (!result.success || !Array.isArray(result.sessions)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = new Set<string>()
|
||||||
|
for (const rowAny of result.sessions) {
|
||||||
|
const row = rowAny as Record<string, unknown>
|
||||||
|
const usernameRaw = row.username ?? row.userName ?? row.talker ?? row.sessionId
|
||||||
|
const username = String(usernameRaw || '').trim()
|
||||||
|
if (!username || !username.endsWith('@chatroom')) continue
|
||||||
|
ids.add(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = Array.from(ids)
|
||||||
|
this.allGroupSessionIdsCache = {
|
||||||
|
ids: list,
|
||||||
|
updatedAt: now
|
||||||
|
}
|
||||||
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectSessionExportStats(
|
private async collectSessionExportStats(
|
||||||
@@ -1715,6 +1938,95 @@ class ChatService {
|
|||||||
return { privateMutualGroupMap, groupMutualFriendMap }
|
return { privateMutualGroupMap, groupMutualFriendMap }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildEmptyExportSessionStats(sessionId: string, includeRelations: boolean): ExportSessionStats {
|
||||||
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
const stats: ExportSessionStats = {
|
||||||
|
totalMessages: 0,
|
||||||
|
voiceMessages: 0,
|
||||||
|
imageMessages: 0,
|
||||||
|
videoMessages: 0,
|
||||||
|
emojiMessages: 0
|
||||||
|
}
|
||||||
|
if (isGroup) {
|
||||||
|
stats.groupMyMessages = 0
|
||||||
|
stats.groupActiveSpeakers = 0
|
||||||
|
stats.groupMemberCount = 0
|
||||||
|
if (includeRelations) {
|
||||||
|
stats.groupMutualFriends = 0
|
||||||
|
}
|
||||||
|
} else if (includeRelations) {
|
||||||
|
stats.privateMutualGroups = 0
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
private async computeSessionExportStats(
|
||||||
|
sessionId: string,
|
||||||
|
selfIdentitySet: Set<string>,
|
||||||
|
includeRelations: boolean
|
||||||
|
): Promise<ExportSessionStats> {
|
||||||
|
const stats = await this.collectSessionExportStats(sessionId, selfIdentitySet)
|
||||||
|
const isGroup = sessionId.endsWith('@chatroom')
|
||||||
|
|
||||||
|
if (isGroup) {
|
||||||
|
const memberCountsResult = await wcdbService.getGroupMemberCounts([sessionId])
|
||||||
|
const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
|
||||||
|
stats.groupMemberCount = typeof memberCountMap[sessionId] === 'number' ? Math.max(0, Math.floor(memberCountMap[sessionId])) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeRelations) {
|
||||||
|
if (isGroup) {
|
||||||
|
try {
|
||||||
|
const { groupMutualFriendMap } = await this.buildGroupRelationStats([sessionId], [], selfIdentitySet)
|
||||||
|
stats.groupMutualFriends = groupMutualFriendMap[sessionId] || 0
|
||||||
|
} catch {
|
||||||
|
stats.groupMutualFriends = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const allGroups = await this.listAllGroupSessionIds()
|
||||||
|
if (allGroups.length === 0) {
|
||||||
|
stats.privateMutualGroups = 0
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const { privateMutualGroupMap } = await this.buildGroupRelationStats(allGroups, [sessionId], selfIdentitySet)
|
||||||
|
stats.privateMutualGroups = privateMutualGroupMap[sessionId] || 0
|
||||||
|
} catch {
|
||||||
|
stats.privateMutualGroups = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrComputeSessionExportStats(
|
||||||
|
sessionId: string,
|
||||||
|
includeRelations: boolean,
|
||||||
|
selfIdentitySet: Set<string>
|
||||||
|
): Promise<ExportSessionStats> {
|
||||||
|
const scopedKey = this.buildScopedSessionStatsKey(sessionId)
|
||||||
|
|
||||||
|
if (!includeRelations) {
|
||||||
|
const pendingFull = this.sessionStatsPendingFull.get(scopedKey)
|
||||||
|
if (pendingFull) return pendingFull
|
||||||
|
const pendingBasic = this.sessionStatsPendingBasic.get(scopedKey)
|
||||||
|
if (pendingBasic) return pendingBasic
|
||||||
|
} else {
|
||||||
|
const pendingFull = this.sessionStatsPendingFull.get(scopedKey)
|
||||||
|
if (pendingFull) return pendingFull
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMap = includeRelations ? this.sessionStatsPendingFull : this.sessionStatsPendingBasic
|
||||||
|
const pending = this.computeSessionExportStats(sessionId, selfIdentitySet, includeRelations)
|
||||||
|
targetMap.set(scopedKey, pending)
|
||||||
|
try {
|
||||||
|
return await pending
|
||||||
|
} finally {
|
||||||
|
targetMap.delete(scopedKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
|
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
|
||||||
*/
|
*/
|
||||||
@@ -3600,6 +3912,14 @@ class ChatService {
|
|||||||
this.voiceTranscriptPending.clear()
|
this.voiceTranscriptPending.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (includeMessages || includeContacts) {
|
||||||
|
this.sessionStatsMemoryCache.clear()
|
||||||
|
this.sessionStatsPendingBasic.clear()
|
||||||
|
this.sessionStatsPendingFull.clear()
|
||||||
|
this.allGroupSessionIdsCache = null
|
||||||
|
this.sessionStatsCacheService.clearAll()
|
||||||
|
}
|
||||||
|
|
||||||
for (const state of this.hardlinkCache.values()) {
|
for (const state of this.hardlinkCache.values()) {
|
||||||
try {
|
try {
|
||||||
state.db?.close()
|
state.db?.close()
|
||||||
@@ -4036,9 +4356,11 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExportSessionStats(sessionIds: string[]): Promise<{
|
async getExportSessionStats(sessionIds: string[], options: ExportSessionStatsOptions = {}): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Record<string, ExportSessionStats>
|
data?: Record<string, ExportSessionStats>
|
||||||
|
cache?: Record<string, ExportSessionStatsCacheMeta>
|
||||||
|
needsRefresh?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
@@ -4046,6 +4368,11 @@ class ChatService {
|
|||||||
if (!connectResult.success) {
|
if (!connectResult.success) {
|
||||||
return { success: false, error: connectResult.error || '数据库未连接' }
|
return { success: false, error: connectResult.error || '数据库未连接' }
|
||||||
}
|
}
|
||||||
|
this.refreshSessionMessageCountCacheScope()
|
||||||
|
|
||||||
|
const includeRelations = options.includeRelations ?? true
|
||||||
|
const forceRefresh = options.forceRefresh === true
|
||||||
|
const allowStaleCache = options.allowStaleCache === true
|
||||||
|
|
||||||
const normalizedSessionIds = Array.from(
|
const normalizedSessionIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@@ -4055,83 +4382,76 @@ class ChatService {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (normalizedSessionIds.length === 0) {
|
if (normalizedSessionIds.length === 0) {
|
||||||
return { success: true, data: {} }
|
return { success: true, data: {}, cache: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
const myWxid = this.configService.get('myWxid') || ''
|
|
||||||
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
|
|
||||||
|
|
||||||
const resultMap: Record<string, ExportSessionStats> = {}
|
const resultMap: Record<string, ExportSessionStats> = {}
|
||||||
await this.forEachWithConcurrency(normalizedSessionIds, 3, async (sessionId) => {
|
const cacheMeta: Record<string, ExportSessionStatsCacheMeta> = {}
|
||||||
try {
|
const needsRefreshSet = new Set<string>()
|
||||||
resultMap[sessionId] = await this.collectSessionExportStats(sessionId, selfIdentitySet)
|
const pendingSessionIds: string[] = []
|
||||||
} catch {
|
const now = Date.now()
|
||||||
resultMap[sessionId] = {
|
|
||||||
totalMessages: 0,
|
for (const sessionId of normalizedSessionIds) {
|
||||||
voiceMessages: 0,
|
if (!forceRefresh) {
|
||||||
imageMessages: 0,
|
const cachedResult = this.getSessionStatsCacheEntry(sessionId)
|
||||||
videoMessages: 0,
|
if (cachedResult && this.supportsRequestedRelation(cachedResult.entry, includeRelations)) {
|
||||||
emojiMessages: 0
|
const stale = now - cachedResult.entry.updatedAt > this.sessionStatsCacheTtlMs
|
||||||
|
if (!stale || allowStaleCache) {
|
||||||
|
resultMap[sessionId] = this.fromSessionStatsCacheStats(cachedResult.entry.stats)
|
||||||
|
cacheMeta[sessionId] = {
|
||||||
|
updatedAt: cachedResult.entry.updatedAt,
|
||||||
|
stale,
|
||||||
|
includeRelations: cachedResult.entry.includeRelations,
|
||||||
|
source: cachedResult.source
|
||||||
}
|
}
|
||||||
|
if (stale) {
|
||||||
|
needsRefreshSet.add(sessionId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (allowStaleCache) {
|
||||||
|
needsRefreshSet.add(sessionId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSessionIds.push(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingSessionIds.length > 0) {
|
||||||
|
const myWxid = this.configService.get('myWxid') || ''
|
||||||
|
const selfIdentitySet = new Set<string>(this.buildIdentityKeys(myWxid))
|
||||||
|
await this.forEachWithConcurrency(pendingSessionIds, 3, async (sessionId) => {
|
||||||
|
try {
|
||||||
|
const stats = await this.getOrComputeSessionExportStats(sessionId, includeRelations, selfIdentitySet)
|
||||||
|
resultMap[sessionId] = stats
|
||||||
|
const updatedAt = this.setSessionStatsCacheEntry(sessionId, stats, includeRelations)
|
||||||
|
cacheMeta[sessionId] = {
|
||||||
|
updatedAt,
|
||||||
|
stale: false,
|
||||||
|
includeRelations,
|
||||||
|
source: 'fresh'
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
resultMap[sessionId] = this.buildEmptyExportSessionStats(sessionId, includeRelations)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const groupSessionIds = normalizedSessionIds.filter((id) => id.endsWith('@chatroom'))
|
|
||||||
const privateSessionIds = normalizedSessionIds.filter((id) => !id.endsWith('@chatroom'))
|
|
||||||
|
|
||||||
for (const privateId of privateSessionIds) {
|
|
||||||
resultMap[privateId] = {
|
|
||||||
...resultMap[privateId],
|
|
||||||
privateMutualGroups: resultMap[privateId]?.privateMutualGroups ?? 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const groupId of groupSessionIds) {
|
|
||||||
resultMap[groupId] = {
|
|
||||||
...resultMap[groupId],
|
|
||||||
groupMyMessages: resultMap[groupId]?.groupMyMessages ?? 0,
|
|
||||||
groupActiveSpeakers: resultMap[groupId]?.groupActiveSpeakers ?? 0,
|
|
||||||
groupMemberCount: resultMap[groupId]?.groupMemberCount ?? 0,
|
|
||||||
groupMutualFriends: resultMap[groupId]?.groupMutualFriends ?? 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupSessionIds.length > 0) {
|
const response: {
|
||||||
const memberCountsResult = await wcdbService.getGroupMemberCounts(groupSessionIds)
|
success: boolean
|
||||||
const memberCountMap = memberCountsResult.success && memberCountsResult.map ? memberCountsResult.map : {}
|
data?: Record<string, ExportSessionStats>
|
||||||
for (const groupId of groupSessionIds) {
|
cache?: Record<string, ExportSessionStatsCacheMeta>
|
||||||
resultMap[groupId] = {
|
needsRefresh?: string[]
|
||||||
...resultMap[groupId],
|
} = {
|
||||||
groupMemberCount: typeof memberCountMap[groupId] === 'number' ? memberCountMap[groupId] : 0
|
success: true,
|
||||||
|
data: resultMap,
|
||||||
|
cache: cacheMeta
|
||||||
}
|
}
|
||||||
|
if (needsRefreshSet.size > 0) {
|
||||||
|
response.needsRefresh = Array.from(needsRefreshSet)
|
||||||
}
|
}
|
||||||
}
|
return response
|
||||||
|
|
||||||
if (groupSessionIds.length > 0) {
|
|
||||||
try {
|
|
||||||
const { privateMutualGroupMap, groupMutualFriendMap } = await this.buildGroupRelationStats(
|
|
||||||
groupSessionIds,
|
|
||||||
privateSessionIds,
|
|
||||||
selfIdentitySet
|
|
||||||
)
|
|
||||||
|
|
||||||
for (const privateId of privateSessionIds) {
|
|
||||||
resultMap[privateId] = {
|
|
||||||
...resultMap[privateId],
|
|
||||||
privateMutualGroups: privateMutualGroupMap[privateId] || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const groupId of groupSessionIds) {
|
|
||||||
resultMap[groupId] = {
|
|
||||||
...resultMap[groupId],
|
|
||||||
groupMutualFriends: groupMutualFriendMap[groupId] || 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 群成员关系统计失败时保留默认值,避免影响主列表展示
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: resultMap }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取导出会话统计失败:', e)
|
console.error('ChatService: 获取导出会话统计失败:', e)
|
||||||
return { success: false, error: String(e) }
|
return { success: false, error: String(e) }
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
|
|||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
|
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
@@ -104,6 +105,9 @@ interface MediaExportItem {
|
|||||||
posterDataUrl?: string
|
posterDataUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MessageCollectMode = 'full' | 'text-fast' | 'media-fast'
|
||||||
|
type MediaContentType = 'voice' | 'image' | 'video' | 'emoji'
|
||||||
|
|
||||||
export interface ExportProgress {
|
export interface ExportProgress {
|
||||||
current: number
|
current: number
|
||||||
total: number
|
total: number
|
||||||
@@ -159,6 +163,85 @@ class ExportService {
|
|||||||
return Math.max(1, Math.min(raw, max))
|
return Math.max(1, Math.min(raw, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isMediaExportEnabled(options: ExportOptions): boolean {
|
||||||
|
return options.exportMedia === true &&
|
||||||
|
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUnboundedDateRange(dateRange?: { start: number; end: number } | null): boolean {
|
||||||
|
if (!dateRange) return true
|
||||||
|
const start = Number.isFinite(dateRange.start) ? dateRange.start : 0
|
||||||
|
const end = Number.isFinite(dateRange.end) ? dateRange.end : 0
|
||||||
|
return start <= 0 && end <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseFastTextCollection(options: ExportOptions): boolean {
|
||||||
|
// 文本批量导出优先走轻量采集:不做媒体字段预提取,减少 CPU 与内存占用
|
||||||
|
return !this.isMediaExportEnabled(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMediaContentType(options: ExportOptions): MediaContentType | null {
|
||||||
|
const value = options.contentType
|
||||||
|
if (value === 'voice' || value === 'image' || value === 'video' || value === 'emoji') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMediaContentBatchExport(options: ExportOptions): boolean {
|
||||||
|
return this.getMediaContentType(options) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTargetMediaLocalTypes(options: ExportOptions): Set<number> {
|
||||||
|
const mediaContentType = this.getMediaContentType(options)
|
||||||
|
if (mediaContentType === 'voice') return new Set([34])
|
||||||
|
if (mediaContentType === 'image') return new Set([3])
|
||||||
|
if (mediaContentType === 'video') return new Set([43])
|
||||||
|
if (mediaContentType === 'emoji') return new Set([47])
|
||||||
|
|
||||||
|
const selected = new Set<number>()
|
||||||
|
if (options.exportImages) selected.add(3)
|
||||||
|
if (options.exportVoices) selected.add(34)
|
||||||
|
if (options.exportVideos) selected.add(43)
|
||||||
|
if (options.exportEmojis) selected.add(47)
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCollectMode(options: ExportOptions): MessageCollectMode {
|
||||||
|
if (this.isMediaContentBatchExport(options)) {
|
||||||
|
return 'media-fast'
|
||||||
|
}
|
||||||
|
return this.shouldUseFastTextCollection(options) ? 'text-fast' : 'full'
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCollectParams(options: ExportOptions): { mode: MessageCollectMode; targetMediaTypes?: Set<number> } {
|
||||||
|
const mode = this.resolveCollectMode(options)
|
||||||
|
if (mode === 'media-fast') {
|
||||||
|
const targetMediaTypes = this.getTargetMediaLocalTypes(options)
|
||||||
|
if (targetMediaTypes.size > 0) {
|
||||||
|
return { mode, targetMediaTypes }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { mode }
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldDecodeMessageContentInFastMode(localType: number): boolean {
|
||||||
|
// 这些类型在文本导出里只需要占位符,无需解码完整 XML / 压缩内容
|
||||||
|
if (localType === 3 || localType === 34 || localType === 42 || localType === 43 || localType === 47) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldDecodeMessageContentInMediaMode(localType: number, targetMediaTypes: Set<number> | null): boolean {
|
||||||
|
if (!targetMediaTypes || !targetMediaTypes.has(localType)) return false
|
||||||
|
// 语音导出仅需要 localId 读取音频数据,不依赖 XML 内容
|
||||||
|
if (localType === 34) return false
|
||||||
|
// 图片/视频/表情可能需要从 XML 提取 md5/datName/cdnUrl
|
||||||
|
if (localType === 3 || localType === 43 || localType === 47) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private cleanAccountDirName(dirName: string): string {
|
private cleanAccountDirName(dirName: string): string {
|
||||||
const trimmed = dirName.trim()
|
const trimmed = dirName.trim()
|
||||||
if (!trimmed) return trimmed
|
if (!trimmed) return trimmed
|
||||||
@@ -1620,7 +1703,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const msgId = String(msg.localId)
|
const msgId = String(msg.localId)
|
||||||
const fileName = `voice_${msgId}.wav`
|
const safeSession = this.cleanAccountDirName(sessionId)
|
||||||
|
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||||
|
.slice(0, 48) || 'session'
|
||||||
|
const fileName = `voice_${safeSession}_${msgId}.wav`
|
||||||
const destPath = path.join(voicesDir, fileName)
|
const destPath = path.join(voicesDir, fileName)
|
||||||
|
|
||||||
// 如果已存在则跳过
|
// 如果已存在则跳过
|
||||||
@@ -1906,21 +1992,35 @@ class ExportService {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
cleanedMyWxid: string,
|
cleanedMyWxid: string,
|
||||||
dateRange?: { start: number; end: number } | null,
|
dateRange?: { start: number; end: number } | null,
|
||||||
senderUsernameFilter?: string
|
senderUsernameFilter?: string,
|
||||||
|
collectMode: MessageCollectMode = 'full',
|
||||||
|
targetMediaTypes?: Set<number>
|
||||||
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
|
||||||
const rows: any[] = []
|
const rows: any[] = []
|
||||||
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
|
||||||
const senderSet = new Set<string>()
|
const senderSet = new Set<string>()
|
||||||
let firstTime: number | null = null
|
let firstTime: number | null = null
|
||||||
let lastTime: number | null = null
|
let lastTime: number | null = null
|
||||||
|
const mediaTypeFilter = collectMode === 'media-fast' && targetMediaTypes && targetMediaTypes.size > 0
|
||||||
|
? targetMediaTypes
|
||||||
|
: null
|
||||||
|
|
||||||
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
// 修复时间范围:0 表示不限制,而不是时间戳 0
|
||||||
const beginTime = dateRange?.start || 0
|
const beginTime = dateRange?.start || 0
|
||||||
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
|
||||||
|
|
||||||
const cursor = await wcdbService.openMessageCursor(
|
const batchSize = (collectMode === 'text-fast' || collectMode === 'media-fast') ? 2000 : 500
|
||||||
|
const cursor = collectMode === 'media-fast'
|
||||||
|
? await wcdbService.openMessageCursorLite(
|
||||||
sessionId,
|
sessionId,
|
||||||
500,
|
batchSize,
|
||||||
|
true,
|
||||||
|
beginTime,
|
||||||
|
endTime
|
||||||
|
)
|
||||||
|
: await wcdbService.openMessageCursor(
|
||||||
|
sessionId,
|
||||||
|
batchSize,
|
||||||
true,
|
true,
|
||||||
beginTime,
|
beginTime,
|
||||||
endTime
|
endTime
|
||||||
@@ -1950,8 +2050,16 @@ class ExportService {
|
|||||||
if (createTime < dateRange.start || createTime > dateRange.end) continue
|
if (createTime < dateRange.start || createTime > dateRange.end) continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = this.decodeMessageContent(row.message_content, row.compress_content)
|
|
||||||
const localType = parseInt(row.local_type || row.type || '1', 10)
|
const localType = parseInt(row.local_type || row.type || '1', 10)
|
||||||
|
if (mediaTypeFilter && !mediaTypeFilter.has(localType)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const shouldDecodeContent = collectMode === 'full'
|
||||||
|
|| (collectMode === 'text-fast' && this.shouldDecodeMessageContentInFastMode(localType))
|
||||||
|
|| (collectMode === 'media-fast' && this.shouldDecodeMessageContentInMediaMode(localType, mediaTypeFilter))
|
||||||
|
const content = shouldDecodeContent
|
||||||
|
? this.decodeMessageContent(row.message_content, row.compress_content)
|
||||||
|
: ''
|
||||||
const senderUsername = row.sender_username || ''
|
const senderUsername = row.sender_username || ''
|
||||||
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
|
||||||
const isSend = parseInt(isSendRaw, 10) === 1
|
const isSend = parseInt(isSendRaw, 10) === 1
|
||||||
@@ -1987,7 +2095,7 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
senderSet.add(actualSender)
|
senderSet.add(actualSender)
|
||||||
|
|
||||||
// 提取媒体相关字段
|
// 提取媒体相关字段(轻量模式下跳过)
|
||||||
let imageMd5: string | undefined
|
let imageMd5: string | undefined
|
||||||
let imageDatName: string | undefined
|
let imageDatName: string | undefined
|
||||||
let emojiCdnUrl: string | undefined
|
let emojiCdnUrl: string | undefined
|
||||||
@@ -1995,24 +2103,33 @@ class ExportService {
|
|||||||
let videoMd5: string | undefined
|
let videoMd5: string | undefined
|
||||||
let chatRecordList: any[] | undefined
|
let chatRecordList: any[] | undefined
|
||||||
|
|
||||||
|
if (collectMode === 'full' || collectMode === 'media-fast') {
|
||||||
|
// 优先复用游标返回的字段,缺失时再回退到 XML 解析。
|
||||||
|
imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || undefined
|
||||||
|
imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || undefined
|
||||||
|
emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || undefined
|
||||||
|
emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || undefined
|
||||||
|
videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || undefined
|
||||||
|
|
||||||
if (localType === 3 && content) {
|
if (localType === 3 && content) {
|
||||||
// 图片消息
|
// 图片消息
|
||||||
imageMd5 = this.extractImageMd5(content)
|
imageMd5 = imageMd5 || this.extractImageMd5(content)
|
||||||
imageDatName = this.extractImageDatName(content)
|
imageDatName = imageDatName || this.extractImageDatName(content)
|
||||||
} else if (localType === 47 && content) {
|
} else if (localType === 47 && content) {
|
||||||
// 动画表情
|
// 动画表情
|
||||||
emojiCdnUrl = this.extractEmojiUrl(content)
|
emojiCdnUrl = emojiCdnUrl || this.extractEmojiUrl(content)
|
||||||
emojiMd5 = this.extractEmojiMd5(content)
|
emojiMd5 = emojiMd5 || this.extractEmojiMd5(content)
|
||||||
} else if (localType === 43 && content) {
|
} else if (localType === 43 && content) {
|
||||||
// 视频消息
|
// 视频消息
|
||||||
videoMd5 = this.extractVideoMd5(content)
|
videoMd5 = videoMd5 || this.extractVideoMd5(content)
|
||||||
} else if (localType === 49 && content) {
|
} else if (collectMode === 'full' && localType === 49 && content) {
|
||||||
// 检查是否是聊天记录消息(type=19)
|
// 检查是否是聊天记录消息(type=19)
|
||||||
const xmlType = this.extractXmlValue(content, 'type')
|
const xmlType = this.extractXmlValue(content, 'type')
|
||||||
if (xmlType === '19') {
|
if (xmlType === '19') {
|
||||||
chatRecordList = this.parseChatHistory(content)
|
chatRecordList = this.parseChatHistory(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
localId,
|
localId,
|
||||||
@@ -2045,6 +2162,10 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collectMode === 'media-fast' && mediaTypeFilter && rows.length > 0) {
|
||||||
|
await this.backfillMediaFieldsFromMessageDetail(sessionId, rows, mediaTypeFilter)
|
||||||
|
}
|
||||||
|
|
||||||
if (senderSet.size > 0) {
|
if (senderSet.size > 0) {
|
||||||
const usernames = Array.from(senderSet)
|
const usernames = Array.from(senderSet)
|
||||||
const [nameResult, avatarResult] = await Promise.all([
|
const [nameResult, avatarResult] = await Promise.all([
|
||||||
@@ -2072,6 +2193,60 @@ class ExportService {
|
|||||||
return { rows, memberSet, firstTime, lastTime }
|
return { rows, memberSet, firstTime, lastTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async backfillMediaFieldsFromMessageDetail(
|
||||||
|
sessionId: string,
|
||||||
|
rows: any[],
|
||||||
|
targetMediaTypes: Set<number>
|
||||||
|
): Promise<void> {
|
||||||
|
const needsBackfill = rows.filter((msg) => {
|
||||||
|
if (!targetMediaTypes.has(msg.localType)) return false
|
||||||
|
if (msg.localType === 3) return !msg.imageMd5 && !msg.imageDatName
|
||||||
|
if (msg.localType === 47) return !msg.emojiMd5 && !msg.emojiCdnUrl
|
||||||
|
if (msg.localType === 43) return !msg.videoMd5
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (needsBackfill.length === 0) return
|
||||||
|
|
||||||
|
const DETAIL_CONCURRENCY = 6
|
||||||
|
await parallelLimit(needsBackfill, DETAIL_CONCURRENCY, async (msg) => {
|
||||||
|
const localId = Number(msg.localId || 0)
|
||||||
|
if (!Number.isFinite(localId) || localId <= 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await wcdbService.getMessageById(sessionId, localId)
|
||||||
|
if (!detail.success || !detail.message) return
|
||||||
|
|
||||||
|
const row = detail.message as any
|
||||||
|
const rawMessageContent = row.message_content ?? row.messageContent ?? row.msg_content ?? row.msgContent ?? ''
|
||||||
|
const rawCompressContent = row.compress_content ?? row.compressContent ?? row.msg_compress_content ?? row.msgCompressContent ?? ''
|
||||||
|
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent)
|
||||||
|
|
||||||
|
if (msg.localType === 3) {
|
||||||
|
const imageMd5 = String(row.image_md5 || row.imageMd5 || '').trim() || this.extractImageMd5(content)
|
||||||
|
const imageDatName = String(row.image_dat_name || row.imageDatName || '').trim() || this.extractImageDatName(content)
|
||||||
|
if (imageMd5) msg.imageMd5 = imageMd5
|
||||||
|
if (imageDatName) msg.imageDatName = imageDatName
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 47) {
|
||||||
|
const emojiMd5 = String(row.emoji_md5 || row.emojiMd5 || '').trim() || this.extractEmojiMd5(content)
|
||||||
|
const emojiCdnUrl = String(row.emoji_cdn_url || row.emojiCdnUrl || '').trim() || this.extractEmojiUrl(content)
|
||||||
|
if (emojiMd5) msg.emojiMd5 = emojiMd5
|
||||||
|
if (emojiCdnUrl) msg.emojiCdnUrl = emojiCdnUrl
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.localType === 43) {
|
||||||
|
const videoMd5 = String(row.video_md5 || row.videoMd5 || '').trim() || this.extractVideoMd5(content)
|
||||||
|
if (videoMd5) msg.videoMd5 = videoMd5
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 详情补取失败时保持降级导出(占位符),避免中断整批任务。
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 补齐群成员,避免只导出发言者导致头像缺失
|
// 补齐群成员,避免只导出发言者导致头像缺失
|
||||||
private async mergeGroupMembers(
|
private async mergeGroupMembers(
|
||||||
chatroomId: string,
|
chatroomId: string,
|
||||||
@@ -2631,7 +2806,15 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
@@ -3026,7 +3209,15 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -3600,7 +3791,15 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -4087,7 +4286,15 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -4367,7 +4574,15 @@ class ExportService {
|
|||||||
phase: 'preparing'
|
phase: 'preparing'
|
||||||
})
|
})
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
return { success: false, error: '该会话在指定时间范围内没有消息' }
|
||||||
}
|
}
|
||||||
@@ -4709,7 +4924,15 @@ class ExportService {
|
|||||||
await this.ensureVoiceModel(onProgress)
|
await this.ensureVoiceModel(onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collectParams = this.resolveCollectParams(options)
|
||||||
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
collectParams.mode,
|
||||||
|
collectParams.targetMediaTypes
|
||||||
|
)
|
||||||
|
|
||||||
// 如果没有消息,不创建文件
|
// 如果没有消息,不创建文件
|
||||||
if (collected.rows.length === 0) {
|
if (collected.rows.length === 0) {
|
||||||
@@ -5187,7 +5410,13 @@ class ExportService {
|
|||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
for (const sessionId of sessionIds) {
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
|
const collected = await this.collectMessages(
|
||||||
|
sessionId,
|
||||||
|
cleanedMyWxid,
|
||||||
|
options.dateRange,
|
||||||
|
options.senderUsername,
|
||||||
|
'text-fast'
|
||||||
|
)
|
||||||
const msgs = collected.rows
|
const msgs = collected.rows
|
||||||
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
const voiceMsgs = msgs.filter(m => m.localType === 34)
|
||||||
const mediaMsgs = msgs.filter(m => {
|
const mediaMsgs = msgs.filter(m => {
|
||||||
@@ -5264,8 +5493,12 @@ class ExportService {
|
|||||||
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
|
return { success: false, successCount: 0, failCount: sessionIds.length, error: conn.error }
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportMediaEnabled = options.exportMedia === true &&
|
const effectiveOptions: ExportOptions = this.isMediaContentBatchExport(options)
|
||||||
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
|
? { ...options, exportVoiceAsText: false }
|
||||||
|
: options
|
||||||
|
|
||||||
|
const exportMediaEnabled = effectiveOptions.exportMedia === true &&
|
||||||
|
Boolean(effectiveOptions.exportImages || effectiveOptions.exportVoices || effectiveOptions.exportVideos || effectiveOptions.exportEmojis)
|
||||||
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
const rawWriteLayout = this.configService.get('exportWriteLayout')
|
||||||
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
const writeLayout = rawWriteLayout === 'A' || rawWriteLayout === 'B' || rawWriteLayout === 'C'
|
||||||
? rawWriteLayout
|
? rawWriteLayout
|
||||||
@@ -5277,17 +5510,29 @@ class ExportService {
|
|||||||
fs.mkdirSync(exportBaseDir, { recursive: true })
|
fs.mkdirSync(exportBaseDir, { recursive: true })
|
||||||
}
|
}
|
||||||
const sessionLayout = exportMediaEnabled
|
const sessionLayout = exportMediaEnabled
|
||||||
? (options.sessionLayout ?? 'per-session')
|
? (effectiveOptions.sessionLayout ?? 'per-session')
|
||||||
: 'shared'
|
: 'shared'
|
||||||
let completedCount = 0
|
let completedCount = 0
|
||||||
const defaultConcurrency = exportMediaEnabled ? 2 : 4
|
const defaultConcurrency = exportMediaEnabled ? 2 : 4
|
||||||
const rawConcurrency = typeof options.exportConcurrency === 'number'
|
const rawConcurrency = typeof effectiveOptions.exportConcurrency === 'number'
|
||||||
? Math.floor(options.exportConcurrency)
|
? Math.floor(effectiveOptions.exportConcurrency)
|
||||||
: defaultConcurrency
|
: defaultConcurrency
|
||||||
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6))
|
const clampedConcurrency = Math.max(1, Math.min(rawConcurrency, 6))
|
||||||
const sessionConcurrency = (exportMediaEnabled && sessionLayout === 'shared')
|
const sessionConcurrency = clampedConcurrency
|
||||||
? 1
|
const emptySessionIds = new Set<string>()
|
||||||
: clampedConcurrency
|
const canFastSkipEmptySessions = this.isUnboundedDateRange(effectiveOptions.dateRange) &&
|
||||||
|
!String(effectiveOptions.senderUsername || '').trim()
|
||||||
|
if (canFastSkipEmptySessions && sessionIds.length > 0) {
|
||||||
|
const countsResult = await wcdbService.getMessageCounts(sessionIds)
|
||||||
|
if (countsResult.success && countsResult.counts) {
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const count = countsResult.counts[sessionId]
|
||||||
|
if (typeof count === 'number' && Number.isFinite(count) && count <= 0) {
|
||||||
|
emptySessionIds.add(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const queue = [...sessionIds]
|
const queue = [...sessionIds]
|
||||||
let pauseRequested = false
|
let pauseRequested = false
|
||||||
let stopRequested = false
|
let stopRequested = false
|
||||||
@@ -5295,6 +5540,21 @@ class ExportService {
|
|||||||
const runOne = async (sessionId: string) => {
|
const runOne = async (sessionId: string) => {
|
||||||
const sessionInfo = await this.getContactInfo(sessionId)
|
const sessionInfo = await this.getContactInfo(sessionId)
|
||||||
|
|
||||||
|
if (emptySessionIds.has(sessionId)) {
|
||||||
|
failCount++
|
||||||
|
failedSessionIds.push(sessionId)
|
||||||
|
completedCount++
|
||||||
|
onProgress?.({
|
||||||
|
current: completedCount,
|
||||||
|
total: sessionIds.length,
|
||||||
|
currentSession: sessionInfo.displayName,
|
||||||
|
currentSessionId: sessionId,
|
||||||
|
phase: 'exporting',
|
||||||
|
phaseLabel: '该会话没有消息,已跳过'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const sessionProgress = (progress: ExportProgress) => {
|
const sessionProgress = (progress: ExportProgress) => {
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
...progress,
|
...progress,
|
||||||
@@ -5314,7 +5574,7 @@ class ExportService {
|
|||||||
|
|
||||||
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
|
||||||
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
|
||||||
const suffix = sanitizeName(options.fileNameSuffix || '')
|
const suffix = sanitizeName(effectiveOptions.fileNameSuffix || '')
|
||||||
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
const safeName = suffix ? `${baseName}_${suffix}` : baseName
|
||||||
const fileNameWithPrefix = `${await this.getSessionFilePrefix(sessionId)}${safeName}`
|
const fileNameWithPrefix = `${await this.getSessionFilePrefix(sessionId)}${safeName}`
|
||||||
const useSessionFolder = sessionLayout === 'per-session'
|
const useSessionFolder = sessionLayout === 'per-session'
|
||||||
@@ -5325,28 +5585,28 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ext = '.json'
|
let ext = '.json'
|
||||||
if (options.format === 'chatlab-jsonl') ext = '.jsonl'
|
if (effectiveOptions.format === 'chatlab-jsonl') ext = '.jsonl'
|
||||||
else if (options.format === 'excel') ext = '.xlsx'
|
else if (effectiveOptions.format === 'excel') ext = '.xlsx'
|
||||||
else if (options.format === 'txt') ext = '.txt'
|
else if (effectiveOptions.format === 'txt') ext = '.txt'
|
||||||
else if (options.format === 'weclone') ext = '.csv'
|
else if (effectiveOptions.format === 'weclone') ext = '.csv'
|
||||||
else if (options.format === 'html') ext = '.html'
|
else if (effectiveOptions.format === 'html') ext = '.html'
|
||||||
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
const outputPath = path.join(sessionDir, `${fileNameWithPrefix}${ext}`)
|
||||||
|
|
||||||
let result: { success: boolean; error?: string }
|
let result: { success: boolean; error?: string }
|
||||||
if (options.format === 'json' || options.format === 'arkme-json') {
|
if (effectiveOptions.format === 'json' || effectiveOptions.format === 'arkme-json') {
|
||||||
result = await this.exportSessionToDetailedJson(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToDetailedJson(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else if (options.format === 'chatlab' || options.format === 'chatlab-jsonl') {
|
} else if (effectiveOptions.format === 'chatlab' || effectiveOptions.format === 'chatlab-jsonl') {
|
||||||
result = await this.exportSessionToChatLab(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToChatLab(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else if (options.format === 'excel') {
|
} else if (effectiveOptions.format === 'excel') {
|
||||||
result = await this.exportSessionToExcel(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToExcel(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else if (options.format === 'txt') {
|
} else if (effectiveOptions.format === 'txt') {
|
||||||
result = await this.exportSessionToTxt(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToTxt(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else if (options.format === 'weclone') {
|
} else if (effectiveOptions.format === 'weclone') {
|
||||||
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToWeCloneCsv(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else if (options.format === 'html') {
|
} else if (effectiveOptions.format === 'html') {
|
||||||
result = await this.exportSessionToHtml(sessionId, outputPath, options, sessionProgress)
|
result = await this.exportSessionToHtml(sessionId, outputPath, effectiveOptions, sessionProgress)
|
||||||
} else {
|
} else {
|
||||||
result = { success: false, error: `不支持的格式: ${options.format}` }
|
result = { success: false, error: `不支持的格式: ${effectiveOptions.format}` }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface GroupMember {
|
|||||||
isOwner?: boolean
|
isOwner?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupMembersPanelEntry extends GroupMember {
|
||||||
|
isFriend: boolean
|
||||||
|
messageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupMessageRank {
|
export interface GroupMessageRank {
|
||||||
member: GroupMember
|
member: GroupMember
|
||||||
messageCount: number
|
messageCount: number
|
||||||
@@ -44,8 +49,26 @@ export interface GroupMediaStats {
|
|||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupMemberContactInfo {
|
||||||
|
remark: string
|
||||||
|
nickName: string
|
||||||
|
alias: string
|
||||||
|
username: string
|
||||||
|
userName: string
|
||||||
|
encryptUsername: string
|
||||||
|
encryptUserName: string
|
||||||
|
localType: number
|
||||||
|
}
|
||||||
|
|
||||||
class GroupAnalyticsService {
|
class GroupAnalyticsService {
|
||||||
private configService: ConfigService
|
private configService: ConfigService
|
||||||
|
private readonly groupMembersPanelCacheTtlMs = 10 * 60 * 1000
|
||||||
|
private readonly groupMembersPanelCache = new Map<string, { updatedAt: number; data: GroupMembersPanelEntry[] }>()
|
||||||
|
private readonly groupMembersPanelInFlight = new Map<
|
||||||
|
string,
|
||||||
|
Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }>
|
||||||
|
>()
|
||||||
|
private readonly friendExcludeNames = new Set(['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage'])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
@@ -419,6 +442,166 @@ class GroupAnalyticsService {
|
|||||||
return Array.from(set)
|
return Array.from(set)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private toNonNegativeInteger(value: unknown): number {
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (!Number.isFinite(parsed)) return 0
|
||||||
|
return Math.max(0, Math.floor(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickStringField(row: Record<string, unknown>, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null) continue
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickIntegerField(row: Record<string, unknown>, keys: string[], fallback: number = 0): number {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = row[key]
|
||||||
|
if (value == null || value === '') continue
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (Number.isFinite(parsed)) return Math.floor(parsed)
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildGroupMembersPanelCacheKey(chatroomId: string): string {
|
||||||
|
const dbPath = String(this.configService.get('dbPath') || '').trim()
|
||||||
|
const wxid = this.cleanAccountDirName(String(this.configService.get('myWxid') || '').trim())
|
||||||
|
return `${dbPath}::${wxid}::${chatroomId}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private pruneGroupMembersPanelCache(maxEntries: number = 80): void {
|
||||||
|
if (this.groupMembersPanelCache.size <= maxEntries) return
|
||||||
|
const entries = Array.from(this.groupMembersPanelCache.entries())
|
||||||
|
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)
|
||||||
|
const removeCount = this.groupMembersPanelCache.size - maxEntries
|
||||||
|
for (let i = 0; i < removeCount; i += 1) {
|
||||||
|
this.groupMembersPanelCache.delete(entries[i][0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMemberContactLookup(usernames: string[]): Promise<Map<string, GroupMemberContactInfo>> {
|
||||||
|
const lookup = new Map<string, GroupMemberContactInfo>()
|
||||||
|
const candidates = this.buildIdCandidates(usernames)
|
||||||
|
if (candidates.length === 0) return lookup
|
||||||
|
|
||||||
|
const batchSize = 200
|
||||||
|
for (let i = 0; i < candidates.length; i += batchSize) {
|
||||||
|
const batch = candidates.slice(i, i + batchSize)
|
||||||
|
if (batch.length === 0) continue
|
||||||
|
|
||||||
|
const inList = batch.map((username) => `'${username.replace(/'/g, "''")}'`).join(',')
|
||||||
|
const sql = `SELECT * FROM contact WHERE username IN (${inList})`
|
||||||
|
const result = await wcdbService.execQuery('contact', null, sql)
|
||||||
|
if (!result.success || !result.rows) continue
|
||||||
|
|
||||||
|
for (const row of result.rows as Record<string, unknown>[]) {
|
||||||
|
const contact: GroupMemberContactInfo = {
|
||||||
|
remark: this.pickStringField(row, ['remark', 'WCDB_CT_remark']),
|
||||||
|
nickName: this.pickStringField(row, ['nick_name', 'nickName', 'WCDB_CT_nick_name']),
|
||||||
|
alias: this.pickStringField(row, ['alias', 'WCDB_CT_alias']),
|
||||||
|
username: this.pickStringField(row, ['username', 'WCDB_CT_username']),
|
||||||
|
userName: this.pickStringField(row, ['user_name', 'userName', 'WCDB_CT_user_name']),
|
||||||
|
encryptUsername: this.pickStringField(row, ['encrypt_username', 'encryptUsername', 'WCDB_CT_encrypt_username']),
|
||||||
|
encryptUserName: this.pickStringField(row, ['encrypt_user_name', 'encryptUserName', 'WCDB_CT_encrypt_user_name']),
|
||||||
|
localType: this.pickIntegerField(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
|
||||||
|
}
|
||||||
|
const lookupKeys = this.buildIdCandidates([
|
||||||
|
contact.username,
|
||||||
|
contact.userName,
|
||||||
|
contact.encryptUsername,
|
||||||
|
contact.encryptUserName,
|
||||||
|
contact.alias
|
||||||
|
])
|
||||||
|
for (const key of lookupKeys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
if (!lookup.has(normalized)) {
|
||||||
|
lookup.set(normalized, contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveContactByCandidates(
|
||||||
|
lookup: Map<string, GroupMemberContactInfo>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): GroupMemberContactInfo | undefined {
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const hit = lookup.get(id.toLowerCase())
|
||||||
|
if (hit) return hit
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildGroupMessageCountLookup(chatroomId: string): Promise<Map<string, number>> {
|
||||||
|
const lookup = new Map<string, number>()
|
||||||
|
const result = await wcdbService.getGroupStats(chatroomId, 0, 0)
|
||||||
|
if (!result.success || !result.data) return lookup
|
||||||
|
|
||||||
|
const sessionData = result.data?.sessions?.[chatroomId]
|
||||||
|
if (!sessionData || !sessionData.senders) return lookup
|
||||||
|
|
||||||
|
const idMap = result.data.idMap || {}
|
||||||
|
for (const [senderId, rawCount] of Object.entries(sessionData.senders as Record<string, number>)) {
|
||||||
|
const username = String(idMap[senderId] || senderId || '').trim()
|
||||||
|
if (!username) continue
|
||||||
|
const count = this.toNonNegativeInteger(rawCount)
|
||||||
|
const keys = this.buildIdCandidates([username])
|
||||||
|
for (const key of keys) {
|
||||||
|
const normalized = key.toLowerCase()
|
||||||
|
const prev = lookup.get(normalized) || 0
|
||||||
|
if (count > prev) {
|
||||||
|
lookup.set(normalized, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lookup
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMessageCountByCandidates(
|
||||||
|
lookup: Map<string, number>,
|
||||||
|
candidates: Array<string | undefined | null>
|
||||||
|
): number {
|
||||||
|
let maxCount = 0
|
||||||
|
const ids = this.buildIdCandidates(candidates)
|
||||||
|
for (const id of ids) {
|
||||||
|
const count = lookup.get(id.toLowerCase())
|
||||||
|
if (typeof count === 'number' && count > maxCount) {
|
||||||
|
maxCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return maxCount
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFriendMember(wxid: string, contact?: GroupMemberContactInfo): boolean {
|
||||||
|
const normalizedWxid = String(wxid || '').trim().toLowerCase()
|
||||||
|
if (!normalizedWxid) return false
|
||||||
|
if (normalizedWxid.includes('@chatroom') || normalizedWxid.startsWith('gh_')) return false
|
||||||
|
if (this.friendExcludeNames.has(normalizedWxid)) return false
|
||||||
|
if (!contact) return false
|
||||||
|
return contact.localType === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortGroupMembersPanelEntries(members: GroupMembersPanelEntry[]): GroupMembersPanelEntry[] {
|
||||||
|
return members.sort((a, b) => {
|
||||||
|
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||||
|
if (ownerDiff !== 0) return ownerDiff
|
||||||
|
|
||||||
|
const friendDiff = Number(Boolean(b.isFriend)) - Number(Boolean(a.isFriend))
|
||||||
|
if (friendDiff !== 0) return friendDiff
|
||||||
|
|
||||||
|
if (a.messageCount !== b.messageCount) return b.messageCount - a.messageCount
|
||||||
|
return a.displayName.localeCompare(b.displayName, 'zh-Hans-CN')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
private resolveGroupNicknameByCandidates(groupNicknames: Map<string, string>, candidates: string[]): string {
|
||||||
const idCandidates = this.buildIdCandidates(candidates)
|
const idCandidates = this.buildIdCandidates(candidates)
|
||||||
if (idCandidates.length === 0) return ''
|
if (idCandidates.length === 0) return ''
|
||||||
@@ -606,6 +789,138 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadGroupMembersPanelDataFresh(
|
||||||
|
chatroomId: string
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string }> {
|
||||||
|
const membersResult = await wcdbService.getGroupMembers(chatroomId)
|
||||||
|
if (!membersResult.success || !membersResult.members) {
|
||||||
|
return { success: false, error: membersResult.error || '获取群成员失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = membersResult.members as Array<{
|
||||||
|
username: string
|
||||||
|
avatarUrl?: string
|
||||||
|
originalName?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}>
|
||||||
|
if (members.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const usernames = members
|
||||||
|
.map((member) => String(member.username || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (usernames.length === 0) return { success: true, data: [] }
|
||||||
|
|
||||||
|
const displayNamesPromise = wcdbService.getDisplayNames(usernames)
|
||||||
|
const contactLookupPromise = this.buildGroupMemberContactLookup(usernames)
|
||||||
|
const ownerPromise = this.detectGroupOwnerUsername(chatroomId, members)
|
||||||
|
const messageCountLookupPromise = this.buildGroupMessageCountLookup(chatroomId)
|
||||||
|
|
||||||
|
const [displayNames, contactLookup, ownerUsername, messageCountLookup] = await Promise.all([
|
||||||
|
displayNamesPromise,
|
||||||
|
contactLookupPromise,
|
||||||
|
ownerPromise,
|
||||||
|
messageCountLookupPromise
|
||||||
|
])
|
||||||
|
|
||||||
|
const nicknameCandidates = this.buildIdCandidates([
|
||||||
|
...members.map((member) => member.username),
|
||||||
|
...members.map((member) => member.originalName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.username),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.userName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUsername),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.encryptUserName),
|
||||||
|
...Array.from(contactLookup.values()).map((contact) => contact?.alias)
|
||||||
|
])
|
||||||
|
const groupNicknames = await this.getGroupNicknamesForRoom(chatroomId, nicknameCandidates)
|
||||||
|
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
|
||||||
|
|
||||||
|
const data: GroupMembersPanelEntry[] = members
|
||||||
|
.map((member) => {
|
||||||
|
const wxid = String(member.username || '').trim()
|
||||||
|
if (!wxid) return null
|
||||||
|
|
||||||
|
const contact = this.resolveContactByCandidates(contactLookup, [wxid, member.originalName])
|
||||||
|
const nickname = contact?.nickName || ''
|
||||||
|
const remark = contact?.remark || ''
|
||||||
|
const alias = contact?.alias || ''
|
||||||
|
const normalizedWxid = this.cleanAccountDirName(wxid)
|
||||||
|
const lookupCandidates = this.buildIdCandidates([
|
||||||
|
wxid,
|
||||||
|
member.originalName as string | undefined,
|
||||||
|
contact?.username,
|
||||||
|
contact?.userName,
|
||||||
|
contact?.encryptUsername,
|
||||||
|
contact?.encryptUserName,
|
||||||
|
alias
|
||||||
|
])
|
||||||
|
if (normalizedWxid === myWxid) {
|
||||||
|
lookupCandidates.push(myWxid)
|
||||||
|
}
|
||||||
|
const groupNickname = this.resolveGroupNicknameByCandidates(groupNicknames, lookupCandidates)
|
||||||
|
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
|
||||||
|
|
||||||
|
return {
|
||||||
|
username: wxid,
|
||||||
|
displayName,
|
||||||
|
nickname,
|
||||||
|
alias,
|
||||||
|
remark,
|
||||||
|
groupNickname,
|
||||||
|
avatarUrl: member.avatarUrl,
|
||||||
|
isOwner: Boolean(ownerUsername && ownerUsername === wxid),
|
||||||
|
isFriend: this.isFriendMember(wxid, contact),
|
||||||
|
messageCount: this.resolveMessageCountByCandidates(messageCountLookup, lookupCandidates)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((member): member is GroupMembersPanelEntry => Boolean(member))
|
||||||
|
|
||||||
|
return { success: true, data: this.sortGroupMembersPanelEntries(data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGroupMembersPanelData(
|
||||||
|
chatroomId: string,
|
||||||
|
forceRefresh: boolean = false
|
||||||
|
): Promise<{ success: boolean; data?: GroupMembersPanelEntry[]; error?: string; fromCache?: boolean; updatedAt?: number }> {
|
||||||
|
try {
|
||||||
|
const normalizedChatroomId = String(chatroomId || '').trim()
|
||||||
|
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
|
||||||
|
|
||||||
|
const cacheKey = this.buildGroupMembersPanelCacheKey(normalizedChatroomId)
|
||||||
|
const now = Date.now()
|
||||||
|
const cached = this.groupMembersPanelCache.get(cacheKey)
|
||||||
|
if (!forceRefresh && cached && now - cached.updatedAt < this.groupMembersPanelCacheTtlMs) {
|
||||||
|
return { success: true, data: cached.data, fromCache: true, updatedAt: cached.updatedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRefresh) {
|
||||||
|
const pending = this.groupMembersPanelInFlight.get(cacheKey)
|
||||||
|
if (pending) return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPromise = (async () => {
|
||||||
|
const conn = await this.ensureConnected()
|
||||||
|
if (!conn.success) return { success: false, error: conn.error }
|
||||||
|
|
||||||
|
const fresh = await this.loadGroupMembersPanelDataFresh(normalizedChatroomId)
|
||||||
|
if (!fresh.success || !fresh.data) {
|
||||||
|
return { success: false, error: fresh.error || '获取群成员面板数据失败' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = Date.now()
|
||||||
|
this.groupMembersPanelCache.set(cacheKey, { updatedAt, data: fresh.data })
|
||||||
|
this.pruneGroupMembersPanelCache()
|
||||||
|
return { success: true, data: fresh.data, fromCache: false, updatedAt }
|
||||||
|
})().finally(() => {
|
||||||
|
this.groupMembersPanelInFlight.delete(cacheKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
this.groupMembersPanelInFlight.set(cacheKey, requestPromise)
|
||||||
|
return await requestPromise
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
async getGroupMembers(chatroomId: string): Promise<{ success: boolean; data?: GroupMember[]; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
276
electron/services/sessionStatsCacheService.ts
Normal file
276
electron/services/sessionStatsCacheService.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'
|
||||||
|
import { ConfigService } from './config'
|
||||||
|
|
||||||
|
const CACHE_VERSION = 1
|
||||||
|
const MAX_SESSION_ENTRIES_PER_SCOPE = 2000
|
||||||
|
const MAX_SCOPE_ENTRIES = 12
|
||||||
|
|
||||||
|
export interface SessionStatsCacheStats {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionStatsCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
includeRelations: boolean
|
||||||
|
stats: SessionStatsCacheStats
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsScopeMap {
|
||||||
|
[sessionId: string]: SessionStatsCacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionStatsCacheStore {
|
||||||
|
version: number
|
||||||
|
scopes: Record<string, SessionStatsScopeMap>
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNonNegativeInt(value: unknown): number | undefined {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined
|
||||||
|
return Math.max(0, Math.floor(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStats(raw: unknown): SessionStatsCacheStats | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
|
||||||
|
const totalMessages = toNonNegativeInt(source.totalMessages)
|
||||||
|
const voiceMessages = toNonNegativeInt(source.voiceMessages)
|
||||||
|
const imageMessages = toNonNegativeInt(source.imageMessages)
|
||||||
|
const videoMessages = toNonNegativeInt(source.videoMessages)
|
||||||
|
const emojiMessages = toNonNegativeInt(source.emojiMessages)
|
||||||
|
|
||||||
|
if (
|
||||||
|
totalMessages === undefined ||
|
||||||
|
voiceMessages === undefined ||
|
||||||
|
imageMessages === undefined ||
|
||||||
|
videoMessages === undefined ||
|
||||||
|
emojiMessages === undefined
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized: SessionStatsCacheStats = {
|
||||||
|
totalMessages,
|
||||||
|
voiceMessages,
|
||||||
|
imageMessages,
|
||||||
|
videoMessages,
|
||||||
|
emojiMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTimestamp = toNonNegativeInt(source.firstTimestamp)
|
||||||
|
if (firstTimestamp !== undefined) normalized.firstTimestamp = firstTimestamp
|
||||||
|
|
||||||
|
const lastTimestamp = toNonNegativeInt(source.lastTimestamp)
|
||||||
|
if (lastTimestamp !== undefined) normalized.lastTimestamp = lastTimestamp
|
||||||
|
|
||||||
|
const privateMutualGroups = toNonNegativeInt(source.privateMutualGroups)
|
||||||
|
if (privateMutualGroups !== undefined) normalized.privateMutualGroups = privateMutualGroups
|
||||||
|
|
||||||
|
const groupMemberCount = toNonNegativeInt(source.groupMemberCount)
|
||||||
|
if (groupMemberCount !== undefined) normalized.groupMemberCount = groupMemberCount
|
||||||
|
|
||||||
|
const groupMyMessages = toNonNegativeInt(source.groupMyMessages)
|
||||||
|
if (groupMyMessages !== undefined) normalized.groupMyMessages = groupMyMessages
|
||||||
|
|
||||||
|
const groupActiveSpeakers = toNonNegativeInt(source.groupActiveSpeakers)
|
||||||
|
if (groupActiveSpeakers !== undefined) normalized.groupActiveSpeakers = groupActiveSpeakers
|
||||||
|
|
||||||
|
const groupMutualFriends = toNonNegativeInt(source.groupMutualFriends)
|
||||||
|
if (groupMutualFriends !== undefined) normalized.groupMutualFriends = groupMutualFriends
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntry(raw: unknown): SessionStatsCacheEntry | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null
|
||||||
|
const source = raw as Record<string, unknown>
|
||||||
|
const updatedAt = toNonNegativeInt(source.updatedAt)
|
||||||
|
const includeRelations = typeof source.includeRelations === 'boolean' ? source.includeRelations : false
|
||||||
|
const stats = normalizeStats(source.stats)
|
||||||
|
|
||||||
|
if (updatedAt === undefined || !stats) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updatedAt,
|
||||||
|
includeRelations,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SessionStatsCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private store: SessionStatsCacheStore = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: ConfigService.getInstance().getCacheBasePath()
|
||||||
|
this.cacheFilePath = join(basePath, 'session-stats.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir(): void {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parsed as Record<string, unknown>
|
||||||
|
const scopesRaw = payload.scopes
|
||||||
|
if (!scopesRaw || typeof scopesRaw !== 'object') {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeValue] of Object.entries(scopesRaw as Record<string, unknown>)) {
|
||||||
|
if (!scopeValue || typeof scopeValue !== 'object') continue
|
||||||
|
const normalizedScope: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entryRaw] of Object.entries(scopeValue as Record<string, unknown>)) {
|
||||||
|
const entry = normalizeEntry(entryRaw)
|
||||||
|
if (!entry) continue
|
||||||
|
normalizedScope[sessionId] = entry
|
||||||
|
}
|
||||||
|
if (Object.keys(normalizedScope).length > 0) {
|
||||||
|
scopes[scopeKey] = normalizedScope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.store = {
|
||||||
|
version: CACHE_VERSION,
|
||||||
|
scopes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 载入缓存失败', error)
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(scopeKey: string, sessionId: string): SessionStatsCacheEntry | undefined {
|
||||||
|
if (!scopeKey || !sessionId) return undefined
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return undefined
|
||||||
|
const entry = normalizeEntry(scope[sessionId])
|
||||||
|
if (!entry) {
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
set(scopeKey: string, sessionId: string, entry: SessionStatsCacheEntry): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const normalized = normalizeEntry(entry)
|
||||||
|
if (!normalized) return
|
||||||
|
|
||||||
|
if (!this.store.scopes[scopeKey]) {
|
||||||
|
this.store.scopes[scopeKey] = {}
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey][sessionId] = normalized
|
||||||
|
|
||||||
|
this.trimScope(scopeKey)
|
||||||
|
this.trimScopes()
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(scopeKey: string, sessionId: string): void {
|
||||||
|
if (!scopeKey || !sessionId) return
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
if (!(sessionId in scope)) return
|
||||||
|
|
||||||
|
delete scope[sessionId]
|
||||||
|
if (Object.keys(scope).length === 0) {
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearScope(scopeKey: string): void {
|
||||||
|
if (!scopeKey) return
|
||||||
|
if (!this.store.scopes[scopeKey]) return
|
||||||
|
delete this.store.scopes[scopeKey]
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll(): void {
|
||||||
|
this.store = { version: CACHE_VERSION, scopes: {} }
|
||||||
|
try {
|
||||||
|
rmSync(this.cacheFilePath, { force: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 清理缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScope(scopeKey: string): void {
|
||||||
|
const scope = this.store.scopes[scopeKey]
|
||||||
|
if (!scope) return
|
||||||
|
const entries = Object.entries(scope)
|
||||||
|
if (entries.length <= MAX_SESSION_ENTRIES_PER_SCOPE) return
|
||||||
|
|
||||||
|
entries.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
|
||||||
|
const trimmed: SessionStatsScopeMap = {}
|
||||||
|
for (const [sessionId, entry] of entries.slice(0, MAX_SESSION_ENTRIES_PER_SCOPE)) {
|
||||||
|
trimmed[sessionId] = entry
|
||||||
|
}
|
||||||
|
this.store.scopes[scopeKey] = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimScopes(): void {
|
||||||
|
const scopeEntries = Object.entries(this.store.scopes)
|
||||||
|
if (scopeEntries.length <= MAX_SCOPE_ENTRIES) return
|
||||||
|
|
||||||
|
scopeEntries.sort((a, b) => {
|
||||||
|
const aUpdatedAt = Math.max(...Object.values(a[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
const bUpdatedAt = Math.max(...Object.values(b[1]).map((entry) => entry.updatedAt), 0)
|
||||||
|
return bUpdatedAt - aUpdatedAt
|
||||||
|
})
|
||||||
|
|
||||||
|
const trimmedScopes: Record<string, SessionStatsScopeMap> = {}
|
||||||
|
for (const [scopeKey, scopeMap] of scopeEntries.slice(0, MAX_SCOPE_ENTRIES)) {
|
||||||
|
trimmedScopes[scopeKey] = scopeMap
|
||||||
|
}
|
||||||
|
this.store.scopes = trimmedScopes
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist(): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.store), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SessionStatsCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"postinstall": "electron-builder install-app-deps",
|
"postinstall": "electron-builder install-app-deps",
|
||||||
"rebuild": "electron-rebuild",
|
"rebuild": "electron-rebuild",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
|
|||||||
@@ -198,11 +198,12 @@ export function GlobalSessionMonitor() {
|
|||||||
// 尝试丰富或获取联系人详情
|
// 尝试丰富或获取联系人详情
|
||||||
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
const contact = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (contact) {
|
if (contact) {
|
||||||
if (contact.remark || contact.nickname) {
|
if (contact.remark || contact.nickName) {
|
||||||
title = contact.remark || contact.nickname
|
title = contact.remark || contact.nickName
|
||||||
}
|
}
|
||||||
if (contact.avatarUrl) {
|
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
avatarUrl = contact.avatarUrl
|
if (avatarResult?.avatarUrl) {
|
||||||
|
avatarUrl = avatarResult.avatarUrl
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 如果不在缓存/数据库中
|
// 如果不在缓存/数据库中
|
||||||
@@ -222,8 +223,11 @@ export function GlobalSessionMonitor() {
|
|||||||
if (title === newSession.username || title.startsWith('wxid_')) {
|
if (title === newSession.username || title.startsWith('wxid_')) {
|
||||||
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
const retried = await window.electronAPI.chat.getContact(newSession.username)
|
||||||
if (retried) {
|
if (retried) {
|
||||||
title = retried.remark || retried.nickname || title
|
title = retried.remark || retried.nickName || title
|
||||||
avatarUrl = retried.avatarUrl || avatarUrl
|
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
|
||||||
|
if (retriedAvatar?.avatarUrl) {
|
||||||
|
avatarUrl = retriedAvatar.avatarUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ function AnnualReportPage() {
|
|||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.annualReport.getAvailableYears()
|
const result = await window.electronAPI.annualReport.getAvailableYears()
|
||||||
if (result.success && result.data && result.data.length > 0) {
|
const years = result.data
|
||||||
setAvailableYears(result.data)
|
if (result.success && Array.isArray(years) && years.length > 0) {
|
||||||
setSelectedYear((prev) => prev ?? result.data[0])
|
setAvailableYears(years)
|
||||||
setSelectedPairYear((prev) => prev ?? result.data[0])
|
setSelectedYear((prev) => prev ?? years[0])
|
||||||
|
setSelectedPairYear((prev) => prev ?? years[0])
|
||||||
} else if (!result.success) {
|
} else if (!result.success) {
|
||||||
setLoadError(result.error || '加载年度数据失败')
|
setLoadError(result.error || '加载年度数据失败')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2727,6 +2727,13 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-stats-meta {
|
||||||
|
margin-top: -6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
@@ -2764,6 +2771,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-inline-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2872,6 +2899,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.group-members-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: #b45309;
|
||||||
|
background: color-mix(in srgb, #f59e0b 10%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.group-members-list {
|
.group-members-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const CHAT_SESSION_LIST_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|||||||
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
const CHAT_SESSION_PREVIEW_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
|
const CHAT_SESSION_PREVIEW_LIMIT_PER_SESSION = 30
|
||||||
const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18
|
const CHAT_SESSION_PREVIEW_MAX_SESSIONS = 18
|
||||||
|
const GROUP_MEMBERS_PANEL_CACHE_TTL_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
function buildChatSessionListCacheKey(scope: string): string {
|
function buildChatSessionListCacheKey(scope: string): string {
|
||||||
return `weflow.chat.sessions.v1::${scope || 'default'}`
|
return `weflow.chat.sessions.v1::${scope || 'default'}`
|
||||||
@@ -186,6 +187,17 @@ function formatYmdDateFromSeconds(timestamp?: number): string {
|
|||||||
return `${y}-${m}-${day}`
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatYmdHmDateTime(timestamp?: number): string {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||||
|
const d = new Date(timestamp)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0')
|
||||||
|
const h = `${d.getHours()}`.padStart(2, '0')
|
||||||
|
const min = `${d.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${h}:${min}`
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatPageProps {
|
interface ChatPageProps {
|
||||||
// 保留接口以备将来扩展
|
// 保留接口以备将来扩展
|
||||||
}
|
}
|
||||||
@@ -208,11 +220,36 @@ interface SessionDetail {
|
|||||||
groupMyMessages?: number
|
groupMyMessages?: number
|
||||||
groupActiveSpeakers?: number
|
groupActiveSpeakers?: number
|
||||||
groupMutualFriends?: number
|
groupMutualFriends?: number
|
||||||
|
relationStatsLoaded?: boolean
|
||||||
|
statsUpdatedAt?: number
|
||||||
|
statsStale?: boolean
|
||||||
firstMessageTime?: number
|
firstMessageTime?: number
|
||||||
latestMessageTime?: number
|
latestMessageTime?: number
|
||||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionExportMetric {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionExportCacheMeta {
|
||||||
|
updatedAt: number
|
||||||
|
stale: boolean
|
||||||
|
includeRelations: boolean
|
||||||
|
source: 'memory' | 'disk' | 'fresh'
|
||||||
|
}
|
||||||
|
|
||||||
interface GroupPanelMember {
|
interface GroupPanelMember {
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -241,6 +278,11 @@ interface SessionPreviewCachePayload {
|
|||||||
entries: Record<string, SessionPreviewCacheEntry>
|
entries: Record<string, SessionPreviewCacheEntry>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GroupMembersPanelCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
members: GroupPanelMember[]
|
||||||
|
}
|
||||||
|
|
||||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
@@ -395,9 +437,13 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
|
||||||
const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false)
|
const [isLoadingDetailExtra, setIsLoadingDetailExtra] = useState(false)
|
||||||
|
const [isRefreshingDetailStats, setIsRefreshingDetailStats] = useState(false)
|
||||||
|
const [isLoadingRelationStats, setIsLoadingRelationStats] = useState(false)
|
||||||
const [groupPanelMembers, setGroupPanelMembers] = useState<GroupPanelMember[]>([])
|
const [groupPanelMembers, setGroupPanelMembers] = useState<GroupPanelMember[]>([])
|
||||||
const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false)
|
const [isLoadingGroupMembers, setIsLoadingGroupMembers] = useState(false)
|
||||||
const [groupMembersError, setGroupMembersError] = useState<string | null>(null)
|
const [groupMembersError, setGroupMembersError] = useState<string | null>(null)
|
||||||
|
const [groupMembersLoadingHint, setGroupMembersLoadingHint] = useState('')
|
||||||
|
const [isRefreshingGroupMembers, setIsRefreshingGroupMembers] = useState(false)
|
||||||
const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('')
|
const [groupMemberSearchKeyword, setGroupMemberSearchKeyword] = useState('')
|
||||||
const [copiedField, setCopiedField] = useState<string | null>(null)
|
const [copiedField, setCopiedField] = useState<string | null>(null)
|
||||||
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
|
||||||
@@ -478,6 +524,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const lastPreloadSessionRef = useRef<string | null>(null)
|
const lastPreloadSessionRef = useRef<string | null>(null)
|
||||||
const detailRequestSeqRef = useRef(0)
|
const detailRequestSeqRef = useRef(0)
|
||||||
const groupMembersRequestSeqRef = useRef(0)
|
const groupMembersRequestSeqRef = useRef(0)
|
||||||
|
const groupMembersPanelCacheRef = useRef<Map<string, GroupMembersPanelCacheEntry>>(new Map())
|
||||||
|
const hasInitializedGroupMembersRef = useRef(false)
|
||||||
const chatCacheScopeRef = useRef('default')
|
const chatCacheScopeRef = useRef('default')
|
||||||
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
|
const previewCacheRef = useRef<Record<string, SessionPreviewCacheEntry>>({})
|
||||||
const previewPersistTimerRef = useRef<number | null>(null)
|
const previewPersistTimerRef = useRef<number | null>(null)
|
||||||
@@ -722,6 +770,40 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const applySessionDetailStats = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
metric: SessionExportMetric,
|
||||||
|
cacheMeta?: SessionExportCacheMeta,
|
||||||
|
relationLoadedOverride?: boolean
|
||||||
|
) => {
|
||||||
|
setSessionDetail((prev) => {
|
||||||
|
if (!prev || prev.wxid !== sessionId) return prev
|
||||||
|
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
|
||||||
|
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
|
||||||
|
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
|
||||||
|
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
|
||||||
|
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
|
||||||
|
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
|
||||||
|
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
|
||||||
|
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
|
||||||
|
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
|
||||||
|
? metric.privateMutualGroups
|
||||||
|
: prev.privateMutualGroups,
|
||||||
|
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
|
||||||
|
? metric.groupMutualFriends
|
||||||
|
: prev.groupMutualFriends,
|
||||||
|
relationStatsLoaded: relationLoaded,
|
||||||
|
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
|
||||||
|
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
|
||||||
|
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
|
||||||
|
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
// 加载会话详情
|
// 加载会话详情
|
||||||
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
const loadSessionDetail = useCallback(async (sessionId: string) => {
|
||||||
const normalizedSessionId = String(sessionId || '').trim()
|
const normalizedSessionId = String(sessionId || '').trim()
|
||||||
@@ -733,6 +815,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
? Math.floor(mappedSession.messageCountHint)
|
? Math.floor(mappedSession.messageCountHint)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
setIsRefreshingDetailStats(false)
|
||||||
|
setIsLoadingRelationStats(false)
|
||||||
setSessionDetail((prev) => {
|
setSessionDetail((prev) => {
|
||||||
const sameSession = prev?.wxid === normalizedSessionId
|
const sameSession = prev?.wxid === normalizedSessionId
|
||||||
return {
|
return {
|
||||||
@@ -752,6 +836,9 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
|
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
|
||||||
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
|
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
|
||||||
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
|
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
|
||||||
|
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
|
||||||
|
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
|
||||||
|
statsStale: sameSession ? prev?.statsStale : undefined,
|
||||||
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
|
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
|
||||||
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
|
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
|
||||||
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
|
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
|
||||||
@@ -781,6 +868,9 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
groupMyMessages: prev?.groupMyMessages,
|
groupMyMessages: prev?.groupMyMessages,
|
||||||
groupActiveSpeakers: prev?.groupActiveSpeakers,
|
groupActiveSpeakers: prev?.groupActiveSpeakers,
|
||||||
groupMutualFriends: prev?.groupMutualFriends,
|
groupMutualFriends: prev?.groupMutualFriends,
|
||||||
|
relationStatsLoaded: prev?.relationStatsLoaded,
|
||||||
|
statsUpdatedAt: prev?.statsUpdatedAt,
|
||||||
|
statsStale: prev?.statsStale,
|
||||||
firstMessageTime: prev?.firstMessageTime,
|
firstMessageTime: prev?.firstMessageTime,
|
||||||
latestMessageTime: prev?.latestMessageTime,
|
latestMessageTime: prev?.latestMessageTime,
|
||||||
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
|
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
|
||||||
@@ -797,47 +887,82 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
try {
|
try {
|
||||||
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||||
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
|
window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: false, allowStaleCache: true }
|
||||||
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
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 {
|
||||||
let next = { ...prev }
|
...prev,
|
||||||
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
|
firstMessageTime: detail.firstMessageTime,
|
||||||
next = {
|
latestMessageTime: detail.latestMessageTime,
|
||||||
...next,
|
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
|
||||||
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
|
|
||||||
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
|
|
||||||
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
|
|
||||||
const metric = statsResultSettled.value.data[normalizedSessionId]
|
|
||||||
if (metric) {
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
|
|
||||||
voiceMessages: metric.voiceMessages,
|
|
||||||
imageMessages: metric.imageMessages,
|
|
||||||
videoMessages: metric.videoMessages,
|
|
||||||
emojiMessages: metric.emojiMessages,
|
|
||||||
privateMutualGroups: metric.privateMutualGroups,
|
|
||||||
groupMemberCount: metric.groupMemberCount,
|
|
||||||
groupMyMessages: metric.groupMyMessages,
|
|
||||||
groupActiveSpeakers: metric.groupActiveSpeakers,
|
|
||||||
groupMutualFriends: metric.groupMutualFriends,
|
|
||||||
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
|
|
||||||
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefreshStats) {
|
||||||
|
setIsRefreshingDetailStats(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: refreshIncludeRelations, forceRefresh: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
if (freshResult.success && freshResult.data) {
|
||||||
|
const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
if (metric) {
|
||||||
|
applySessionDetailStats(
|
||||||
|
normalizedSessionId,
|
||||||
|
metric,
|
||||||
|
cacheMeta,
|
||||||
|
refreshIncludeRelations ? true : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新会话统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsRefreshingDetailStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('加载会话详情补充统计失败:', e)
|
console.error('加载会话详情补充统计失败:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -845,51 +970,120 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setIsLoadingDetailExtra(false)
|
setIsLoadingDetailExtra(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [applySessionDetailStats])
|
||||||
|
|
||||||
|
const loadRelationStats = useCallback(async () => {
|
||||||
|
const normalizedSessionId = String(currentSessionId || '').trim()
|
||||||
|
if (!normalizedSessionId || isLoadingRelationStats) return
|
||||||
|
|
||||||
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
setIsLoadingRelationStats(true)
|
||||||
|
try {
|
||||||
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: true, allowStaleCache: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
|
const metric = relationResult.success && relationResult.data
|
||||||
|
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
: undefined
|
||||||
|
const cacheMeta = relationResult.success
|
||||||
|
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
: undefined
|
||||||
|
if (metric) {
|
||||||
|
applySessionDetailStats(normalizedSessionId, metric, cacheMeta, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const needRefresh = relationResult.success &&
|
||||||
|
Array.isArray(relationResult.needsRefresh) &&
|
||||||
|
relationResult.needsRefresh.includes(normalizedSessionId)
|
||||||
|
|
||||||
|
if (needRefresh) {
|
||||||
|
setIsRefreshingDetailStats(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: true, forceRefresh: 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) {
|
||||||
|
setIsRefreshingDetailStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载会话关系统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsLoadingRelationStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [applySessionDetailStats, currentSessionId, isLoadingRelationStats])
|
||||||
|
|
||||||
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
|
const loadGroupMembersPanel = useCallback(async (chatroomId: string) => {
|
||||||
if (!chatroomId || !isGroupChatSession(chatroomId)) return
|
if (!chatroomId || !isGroupChatSession(chatroomId)) return
|
||||||
|
|
||||||
const requestSeq = ++groupMembersRequestSeqRef.current
|
const requestSeq = ++groupMembersRequestSeqRef.current
|
||||||
setIsLoadingGroupMembers(true)
|
const now = Date.now()
|
||||||
|
const cached = groupMembersPanelCacheRef.current.get(chatroomId)
|
||||||
|
const cacheFresh = Boolean(cached && now - cached.updatedAt < GROUP_MEMBERS_PANEL_CACHE_TTL_MS)
|
||||||
|
const hasCachedMembers = Boolean(cached && cached.members.length > 0)
|
||||||
|
|
||||||
|
if (cacheFresh && cached) {
|
||||||
|
setGroupPanelMembers(cached.members)
|
||||||
setGroupMembersError(null)
|
setGroupMembersError(null)
|
||||||
|
setGroupMembersLoadingHint('')
|
||||||
try {
|
setIsRefreshingGroupMembers(false)
|
||||||
const [membersResult, rankingResult, contactsResult] = await Promise.all([
|
setIsLoadingGroupMembers(false)
|
||||||
window.electronAPI.groupAnalytics.getGroupMembers(chatroomId),
|
hasInitializedGroupMembersRef.current = true
|
||||||
window.electronAPI.groupAnalytics.getGroupMessageRanking(chatroomId, 20000),
|
|
||||||
window.electronAPI.chat.getContacts()
|
|
||||||
])
|
|
||||||
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
|
||||||
|
|
||||||
if (!membersResult.success || !Array.isArray(membersResult.data)) {
|
|
||||||
setGroupPanelMembers([])
|
|
||||||
setGroupMembersError(membersResult.error || '加载群成员失败')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageCountMap = new Map<string, number>()
|
setGroupMembersError(null)
|
||||||
if (rankingResult.success && Array.isArray(rankingResult.data)) {
|
if (hasCachedMembers && cached) {
|
||||||
for (const rank of rankingResult.data) {
|
setGroupPanelMembers(cached.members)
|
||||||
const username = String(rank.member?.username || '').trim()
|
setIsRefreshingGroupMembers(true)
|
||||||
if (!username) continue
|
setGroupMembersLoadingHint('')
|
||||||
const count = Number.isFinite(rank.messageCount) ? Math.max(0, Math.floor(rank.messageCount)) : 0
|
setIsLoadingGroupMembers(false)
|
||||||
messageCountMap.set(username, count)
|
} else {
|
||||||
}
|
setGroupPanelMembers([])
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
|
setIsLoadingGroupMembers(true)
|
||||||
|
setGroupMembersLoadingHint(
|
||||||
|
hasInitializedGroupMembersRef.current
|
||||||
|
? '加载群成员中...'
|
||||||
|
: '首次加载群成员,正在初始化索引(可能需要几秒)'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendSet = new Set<string>()
|
try {
|
||||||
if (contactsResult.success && Array.isArray(contactsResult.contacts)) {
|
const membersResult = await window.electronAPI.groupAnalytics.getGroupMembersPanelData(chatroomId)
|
||||||
for (const contact of contactsResult.contacts) {
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
if (contact.type !== 'friend') continue
|
|
||||||
const username = String(contact.username || '').trim()
|
if (!membersResult.success || !Array.isArray(membersResult.data)) {
|
||||||
if (!username) continue
|
if (!hasCachedMembers) {
|
||||||
friendSet.add(username)
|
setGroupPanelMembers([])
|
||||||
}
|
}
|
||||||
|
setGroupMembersError(membersResult.error || (hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : '加载群成员失败'))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const members: GroupPanelMember[] = membersResult.data
|
const membersPayload = membersResult.data as GroupPanelMember[]
|
||||||
.map((member) => {
|
const members: GroupPanelMember[] = membersPayload
|
||||||
|
.map((member: GroupPanelMember): GroupPanelMember | null => {
|
||||||
const username = String(member.username || '').trim()
|
const username = String(member.username || '').trim()
|
||||||
if (!username) return null
|
if (!username) return null
|
||||||
const preferredName = String(
|
const preferredName = String(
|
||||||
@@ -909,12 +1103,12 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
remark: member.remark,
|
remark: member.remark,
|
||||||
groupNickname: member.groupNickname,
|
groupNickname: member.groupNickname,
|
||||||
isOwner: Boolean(member.isOwner),
|
isOwner: Boolean(member.isOwner),
|
||||||
isFriend: friendSet.has(username),
|
isFriend: Boolean(member.isFriend),
|
||||||
messageCount: messageCountMap.get(username) || 0
|
messageCount: Number.isFinite(member.messageCount) ? Math.max(0, Math.floor(member.messageCount)) : 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((member): member is GroupPanelMember => Boolean(member))
|
.filter((member: GroupPanelMember | null): member is GroupPanelMember => Boolean(member))
|
||||||
.sort((a, b) => {
|
.sort((a: GroupPanelMember, b: GroupPanelMember) => {
|
||||||
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
const ownerDiff = Number(Boolean(b.isOwner)) - Number(Boolean(a.isOwner))
|
||||||
if (ownerDiff !== 0) return ownerDiff
|
if (ownerDiff !== 0) return ownerDiff
|
||||||
|
|
||||||
@@ -926,19 +1120,33 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setGroupPanelMembers(members)
|
setGroupPanelMembers(members)
|
||||||
if (!rankingResult.success) {
|
setGroupMembersError(null)
|
||||||
setGroupMembersError(rankingResult.error || '群成员发言统计加载失败')
|
groupMembersPanelCacheRef.current.set(chatroomId, {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
members
|
||||||
|
})
|
||||||
|
if (groupMembersPanelCacheRef.current.size > 80) {
|
||||||
|
const oldestEntry = Array.from(groupMembersPanelCacheRef.current.entries())
|
||||||
|
.sort((a, b) => a[1].updatedAt - b[1].updatedAt)[0]
|
||||||
|
if (oldestEntry) {
|
||||||
|
groupMembersPanelCacheRef.current.delete(oldestEntry[0])
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
hasInitializedGroupMembersRef.current = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
if (requestSeq !== groupMembersRequestSeqRef.current) return
|
||||||
|
if (!hasCachedMembers) {
|
||||||
setGroupPanelMembers([])
|
setGroupPanelMembers([])
|
||||||
setGroupMembersError(String(e))
|
}
|
||||||
|
setGroupMembersError(hasCachedMembers ? '刷新群成员失败,已显示缓存数据' : String(e))
|
||||||
} finally {
|
} finally {
|
||||||
if (requestSeq === groupMembersRequestSeqRef.current) {
|
if (requestSeq === groupMembersRequestSeqRef.current) {
|
||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
|
setGroupMembersLoadingHint('')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isGroupChatSession])
|
||||||
|
|
||||||
const toggleGroupMembersPanel = useCallback(() => {
|
const toggleGroupMembersPanel = useCallback(() => {
|
||||||
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
if (!currentSessionId || !isGroupChatSession(currentSessionId)) return
|
||||||
@@ -1024,12 +1232,18 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
pendingSessionLoadRef.current = null
|
pendingSessionLoadRef.current = null
|
||||||
setIsSessionSwitching(false)
|
setIsSessionSwitching(false)
|
||||||
setSessionDetail(null)
|
setSessionDetail(null)
|
||||||
|
setIsRefreshingDetailStats(false)
|
||||||
|
setIsLoadingRelationStats(false)
|
||||||
setShowDetailPanel(false)
|
setShowDetailPanel(false)
|
||||||
setShowGroupMembersPanel(false)
|
setShowGroupMembersPanel(false)
|
||||||
setGroupPanelMembers([])
|
setGroupPanelMembers([])
|
||||||
setGroupMembersError(null)
|
setGroupMembersError(null)
|
||||||
|
setGroupMembersLoadingHint('')
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
setGroupMemberSearchKeyword('')
|
setGroupMemberSearchKeyword('')
|
||||||
groupMembersRequestSeqRef.current += 1
|
groupMembersRequestSeqRef.current += 1
|
||||||
|
groupMembersPanelCacheRef.current.clear()
|
||||||
|
hasInitializedGroupMembersRef.current = false
|
||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
setCurrentSession(null)
|
setCurrentSession(null)
|
||||||
setSessions([])
|
setSessions([])
|
||||||
@@ -1694,9 +1908,13 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setShowGroupMembersPanel(false)
|
setShowGroupMembersPanel(false)
|
||||||
setGroupMemberSearchKeyword('')
|
setGroupMemberSearchKeyword('')
|
||||||
setGroupMembersError(null)
|
setGroupMembersError(null)
|
||||||
|
setGroupMembersLoadingHint('')
|
||||||
|
setIsRefreshingGroupMembers(false)
|
||||||
groupMembersRequestSeqRef.current += 1
|
groupMembersRequestSeqRef.current += 1
|
||||||
setIsLoadingGroupMembers(false)
|
setIsLoadingGroupMembers(false)
|
||||||
setSessionDetail(null)
|
setSessionDetail(null)
|
||||||
|
setIsRefreshingDetailStats(false)
|
||||||
|
setIsLoadingRelationStats(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索过滤
|
// 搜索过滤
|
||||||
@@ -3146,10 +3364,22 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isRefreshingGroupMembers && (
|
||||||
|
<div className="group-members-status" role="status" aria-live="polite">
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
<span>正在更新群成员数据...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupMembersError && groupPanelMembers.length > 0 && (
|
||||||
|
<div className="group-members-status warning" role="status" aria-live="polite">
|
||||||
|
<span>{groupMembersError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoadingGroupMembers ? (
|
{isLoadingGroupMembers ? (
|
||||||
<div className="detail-loading">
|
<div className="detail-loading">
|
||||||
<Loader2 size={20} className="spin" />
|
<Loader2 size={20} className="spin" />
|
||||||
<span>加载群成员中...</span>
|
<span>{groupMembersLoadingHint || '加载群成员中...'}</span>
|
||||||
</div>
|
</div>
|
||||||
) : groupMembersError && groupPanelMembers.length === 0 ? (
|
) : groupMembersError && groupPanelMembers.length === 0 ? (
|
||||||
<div className="detail-empty">{groupMembersError}</div>
|
<div className="detail-empty">{groupMembersError}</div>
|
||||||
@@ -3256,6 +3486,13 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<MessageSquare size={14} />
|
<MessageSquare size={14} />
|
||||||
<span>消息统计(导出口径)</span>
|
<span>消息统计(导出口径)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="detail-stats-meta">
|
||||||
|
{isRefreshingDetailStats
|
||||||
|
? '统计刷新中...'
|
||||||
|
: sessionDetail.statsUpdatedAt
|
||||||
|
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
|
||||||
|
: (isLoadingDetailExtra ? '统计加载中...' : '暂无统计缓存')}
|
||||||
|
</div>
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">消息总数</span>
|
<span className="label">消息总数</span>
|
||||||
<span className="value highlight">
|
<span className="value highlight">
|
||||||
@@ -3325,9 +3562,19 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">群共同好友数</span>
|
<span className="label">群共同好友数</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{Number.isFinite(sessionDetail.groupMutualFriends)
|
{sessionDetail.relationStatsLoaded
|
||||||
|
? (Number.isFinite(sessionDetail.groupMutualFriends)
|
||||||
? (sessionDetail.groupMutualFriends as number).toLocaleString()
|
? (sessionDetail.groupMutualFriends as number).toLocaleString()
|
||||||
: (isLoadingDetailExtra ? '统计中...' : '—')}
|
: '—')
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="detail-inline-btn"
|
||||||
|
onClick={() => { void loadRelationStats() }}
|
||||||
|
disabled={isLoadingRelationStats || isLoadingDetailExtra}
|
||||||
|
>
|
||||||
|
{isLoadingRelationStats ? '加载中...' : '点击加载'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3335,9 +3582,19 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">共同群聊数</span>
|
<span className="label">共同群聊数</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{Number.isFinite(sessionDetail.privateMutualGroups)
|
{sessionDetail.relationStatsLoaded
|
||||||
|
? (Number.isFinite(sessionDetail.privateMutualGroups)
|
||||||
? (sessionDetail.privateMutualGroups as number).toLocaleString()
|
? (sessionDetail.privateMutualGroups as number).toLocaleString()
|
||||||
: (isLoadingDetailExtra ? '统计中...' : '—')}
|
: '—')
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="detail-inline-btn"
|
||||||
|
onClick={() => { void loadRelationStats() }}
|
||||||
|
disabled={isLoadingRelationStats || isLoadingDetailExtra}
|
||||||
|
>
|
||||||
|
{isLoadingRelationStats ? '加载中...' : '点击加载'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -107,7 +107,16 @@ function DualReportWindow() {
|
|||||||
setLoadingStage('完成')
|
setLoadingStage('完成')
|
||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setReportData(result.data)
|
const normalizedResponse = result.data.response
|
||||||
|
? {
|
||||||
|
...result.data.response,
|
||||||
|
slowest: result.data.response.slowest ?? result.data.response.avg
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
setReportData({
|
||||||
|
...result.data,
|
||||||
|
response: normalizedResponse
|
||||||
|
})
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || '生成报告失败')
|
setError(result.error || '生成报告失败')
|
||||||
|
|||||||
@@ -1411,6 +1411,13 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.4px;
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-stats-meta {
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
@@ -1443,6 +1450,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-inline-btn {
|
||||||
|
border: none;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--primary);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.copy-btn {
|
.copy-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -378,6 +378,17 @@ const formatYmdDateFromSeconds = (timestamp?: number): string => {
|
|||||||
return `${y}-${m}-${day}`
|
return `${y}-${m}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatYmdHmDateTime = (timestamp?: number): string => {
|
||||||
|
if (!timestamp || !Number.isFinite(timestamp)) return '—'
|
||||||
|
const d = new Date(timestamp)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${d.getDate()}`.padStart(2, '0')
|
||||||
|
const h = `${d.getHours()}`.padStart(2, '0')
|
||||||
|
const min = `${d.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${h}:${min}`
|
||||||
|
}
|
||||||
|
|
||||||
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
const formatRecentExportTime = (timestamp?: number, now = Date.now()): string => {
|
||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
const diff = Math.max(0, now - timestamp)
|
const diff = Math.max(0, now - timestamp)
|
||||||
@@ -496,11 +507,36 @@ interface SessionDetail {
|
|||||||
groupMyMessages?: number
|
groupMyMessages?: number
|
||||||
groupActiveSpeakers?: number
|
groupActiveSpeakers?: number
|
||||||
groupMutualFriends?: number
|
groupMutualFriends?: number
|
||||||
|
relationStatsLoaded?: boolean
|
||||||
|
statsUpdatedAt?: number
|
||||||
|
statsStale?: boolean
|
||||||
firstMessageTime?: number
|
firstMessageTime?: number
|
||||||
latestMessageTime?: number
|
latestMessageTime?: number
|
||||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SessionExportMetric {
|
||||||
|
totalMessages: number
|
||||||
|
voiceMessages: number
|
||||||
|
imageMessages: number
|
||||||
|
videoMessages: number
|
||||||
|
emojiMessages: number
|
||||||
|
firstTimestamp?: number
|
||||||
|
lastTimestamp?: number
|
||||||
|
privateMutualGroups?: number
|
||||||
|
groupMemberCount?: number
|
||||||
|
groupMyMessages?: number
|
||||||
|
groupActiveSpeakers?: number
|
||||||
|
groupMutualFriends?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionExportCacheMeta {
|
||||||
|
updatedAt: number
|
||||||
|
stale: boolean
|
||||||
|
includeRelations: boolean
|
||||||
|
source: 'memory' | 'disk' | 'fresh'
|
||||||
|
}
|
||||||
|
|
||||||
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
const withTimeout = async <T,>(promise: Promise<T>, timeoutMs: number): Promise<T | null> => {
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
try {
|
try {
|
||||||
@@ -772,6 +808,8 @@ function ExportPage() {
|
|||||||
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
const [sessionDetail, setSessionDetail] = useState<SessionDetail | null>(null)
|
||||||
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
|
const [isLoadingSessionDetail, setIsLoadingSessionDetail] = useState(false)
|
||||||
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
|
const [isLoadingSessionDetailExtra, setIsLoadingSessionDetailExtra] = useState(false)
|
||||||
|
const [isRefreshingSessionDetailStats, setIsRefreshingSessionDetailStats] = useState(false)
|
||||||
|
const [isLoadingSessionRelationStats, setIsLoadingSessionRelationStats] = useState(false)
|
||||||
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
const [copiedDetailField, setCopiedDetailField] = useState<string | null>(null)
|
||||||
|
|
||||||
const [exportFolder, setExportFolder] = useState('')
|
const [exportFolder, setExportFolder] = useState('')
|
||||||
@@ -1718,6 +1756,7 @@ function ExportPage() {
|
|||||||
next.exportVoices = payload.contentType === 'voice'
|
next.exportVoices = payload.contentType === 'voice'
|
||||||
next.exportVideos = payload.contentType === 'video'
|
next.exportVideos = payload.contentType === 'video'
|
||||||
next.exportEmojis = payload.contentType === 'emoji'
|
next.exportEmojis = payload.contentType === 'emoji'
|
||||||
|
next.exportVoiceAsText = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1813,6 +1852,7 @@ function ExportPage() {
|
|||||||
if (contentType === 'text') {
|
if (contentType === 'text') {
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
contentType,
|
||||||
exportAvatars: true,
|
exportAvatars: true,
|
||||||
exportMedia: false,
|
exportMedia: false,
|
||||||
exportImages: false,
|
exportImages: false,
|
||||||
@@ -1824,11 +1864,13 @@ function ExportPage() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
|
contentType,
|
||||||
exportMedia: true,
|
exportMedia: true,
|
||||||
exportImages: contentType === 'image',
|
exportImages: contentType === 'image',
|
||||||
exportVoices: contentType === 'voice',
|
exportVoices: contentType === 'voice',
|
||||||
exportVideos: contentType === 'video',
|
exportVideos: contentType === 'video',
|
||||||
exportEmojis: contentType === 'emoji'
|
exportEmojis: contentType === 'emoji',
|
||||||
|
exportVoiceAsText: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2048,6 +2090,8 @@ function ExportPage() {
|
|||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
const doneAt = Date.now()
|
const doneAt = Date.now()
|
||||||
|
const successCount = result.successCount ?? 0
|
||||||
|
const failCount = result.failCount ?? 0
|
||||||
const contentTypes = next.payload.contentType
|
const contentTypes = next.payload.contentType
|
||||||
? [next.payload.contentType]
|
? [next.payload.contentType]
|
||||||
: inferContentTypesFromOptions(next.payload.options)
|
: inferContentTypesFromOptions(next.payload.options)
|
||||||
@@ -2067,7 +2111,7 @@ function ExportPage() {
|
|||||||
finishedAt: doneAt,
|
finishedAt: doneAt,
|
||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
current: result.successCount + result.failCount,
|
current: successCount + failCount,
|
||||||
total: task.progress.total || next.payload.sessionIds.length,
|
total: task.progress.total || next.payload.sessionIds.length,
|
||||||
phaseLabel: '已停止'
|
phaseLabel: '已停止'
|
||||||
},
|
},
|
||||||
@@ -2112,7 +2156,7 @@ function ExportPage() {
|
|||||||
},
|
},
|
||||||
progress: {
|
progress: {
|
||||||
...task.progress,
|
...task.progress,
|
||||||
current: result.successCount + result.failCount,
|
current: successCount + failCount,
|
||||||
total: task.progress.total || next.payload.sessionIds.length,
|
total: task.progress.total || next.payload.sessionIds.length,
|
||||||
phaseLabel: '已暂停'
|
phaseLabel: '已暂停'
|
||||||
},
|
},
|
||||||
@@ -2531,6 +2575,40 @@ function ExportPage() {
|
|||||||
return map
|
return map
|
||||||
}, [contactsList])
|
}, [contactsList])
|
||||||
|
|
||||||
|
const applySessionDetailStats = useCallback((
|
||||||
|
sessionId: string,
|
||||||
|
metric: SessionExportMetric,
|
||||||
|
cacheMeta?: SessionExportCacheMeta,
|
||||||
|
relationLoadedOverride?: boolean
|
||||||
|
) => {
|
||||||
|
setSessionDetail((prev) => {
|
||||||
|
if (!prev || prev.wxid !== sessionId) return prev
|
||||||
|
const relationLoaded = relationLoadedOverride ?? Boolean(prev.relationStatsLoaded)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : prev.messageCount,
|
||||||
|
voiceMessages: Number.isFinite(metric.voiceMessages) ? metric.voiceMessages : prev.voiceMessages,
|
||||||
|
imageMessages: Number.isFinite(metric.imageMessages) ? metric.imageMessages : prev.imageMessages,
|
||||||
|
videoMessages: Number.isFinite(metric.videoMessages) ? metric.videoMessages : prev.videoMessages,
|
||||||
|
emojiMessages: Number.isFinite(metric.emojiMessages) ? metric.emojiMessages : prev.emojiMessages,
|
||||||
|
groupMemberCount: Number.isFinite(metric.groupMemberCount) ? metric.groupMemberCount : prev.groupMemberCount,
|
||||||
|
groupMyMessages: Number.isFinite(metric.groupMyMessages) ? metric.groupMyMessages : prev.groupMyMessages,
|
||||||
|
groupActiveSpeakers: Number.isFinite(metric.groupActiveSpeakers) ? metric.groupActiveSpeakers : prev.groupActiveSpeakers,
|
||||||
|
privateMutualGroups: relationLoaded && Number.isFinite(metric.privateMutualGroups)
|
||||||
|
? metric.privateMutualGroups
|
||||||
|
: prev.privateMutualGroups,
|
||||||
|
groupMutualFriends: relationLoaded && Number.isFinite(metric.groupMutualFriends)
|
||||||
|
? metric.groupMutualFriends
|
||||||
|
: prev.groupMutualFriends,
|
||||||
|
relationStatsLoaded: relationLoaded,
|
||||||
|
statsUpdatedAt: cacheMeta?.updatedAt ?? prev.statsUpdatedAt,
|
||||||
|
statsStale: typeof cacheMeta?.stale === 'boolean' ? cacheMeta.stale : prev.statsStale,
|
||||||
|
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : prev.firstMessageTime,
|
||||||
|
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : prev.latestMessageTime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
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
|
||||||
@@ -2543,6 +2621,8 @@ function ExportPage() {
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
setCopiedDetailField(null)
|
setCopiedDetailField(null)
|
||||||
|
setIsRefreshingSessionDetailStats(false)
|
||||||
|
setIsLoadingSessionRelationStats(false)
|
||||||
setSessionDetail((prev) => {
|
setSessionDetail((prev) => {
|
||||||
const sameSession = prev?.wxid === normalizedSessionId
|
const sameSession = prev?.wxid === normalizedSessionId
|
||||||
return {
|
return {
|
||||||
@@ -2562,6 +2642,9 @@ function ExportPage() {
|
|||||||
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
|
groupMyMessages: sameSession ? prev?.groupMyMessages : undefined,
|
||||||
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
|
groupActiveSpeakers: sameSession ? prev?.groupActiveSpeakers : undefined,
|
||||||
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
|
groupMutualFriends: sameSession ? prev?.groupMutualFriends : undefined,
|
||||||
|
relationStatsLoaded: sameSession ? prev?.relationStatsLoaded : false,
|
||||||
|
statsUpdatedAt: sameSession ? prev?.statsUpdatedAt : undefined,
|
||||||
|
statsStale: sameSession ? prev?.statsStale : undefined,
|
||||||
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
|
firstMessageTime: sameSession ? prev?.firstMessageTime : undefined,
|
||||||
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
|
latestMessageTime: sameSession ? prev?.latestMessageTime : undefined,
|
||||||
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
|
messageTables: sameSession && Array.isArray(prev?.messageTables) ? prev.messageTables : []
|
||||||
@@ -2591,6 +2674,9 @@ function ExportPage() {
|
|||||||
groupMyMessages: prev?.groupMyMessages,
|
groupMyMessages: prev?.groupMyMessages,
|
||||||
groupActiveSpeakers: prev?.groupActiveSpeakers,
|
groupActiveSpeakers: prev?.groupActiveSpeakers,
|
||||||
groupMutualFriends: prev?.groupMutualFriends,
|
groupMutualFriends: prev?.groupMutualFriends,
|
||||||
|
relationStatsLoaded: prev?.relationStatsLoaded,
|
||||||
|
statsUpdatedAt: prev?.statsUpdatedAt,
|
||||||
|
statsStale: prev?.statsStale,
|
||||||
firstMessageTime: prev?.firstMessageTime,
|
firstMessageTime: prev?.firstMessageTime,
|
||||||
latestMessageTime: prev?.latestMessageTime,
|
latestMessageTime: prev?.latestMessageTime,
|
||||||
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
|
messageTables: Array.isArray(prev?.messageTables) ? (prev?.messageTables || []) : []
|
||||||
@@ -2607,47 +2693,82 @@ function ExportPage() {
|
|||||||
try {
|
try {
|
||||||
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
const [extraResultSettled, statsResultSettled] = await Promise.allSettled([
|
||||||
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
window.electronAPI.chat.getSessionDetailExtra(normalizedSessionId),
|
||||||
window.electronAPI.chat.getExportSessionStats([normalizedSessionId])
|
window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: false, allowStaleCache: true }
|
||||||
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
if (requestSeq !== detailRequestSeqRef.current) return
|
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 {
|
||||||
let next = { ...prev }
|
...prev,
|
||||||
if (extraResultSettled.status === 'fulfilled' && extraResultSettled.value.success && extraResultSettled.value.detail) {
|
firstMessageTime: detail.firstMessageTime,
|
||||||
next = {
|
latestMessageTime: detail.latestMessageTime,
|
||||||
...next,
|
messageTables: Array.isArray(detail.messageTables) ? detail.messageTables : []
|
||||||
firstMessageTime: extraResultSettled.value.detail.firstMessageTime,
|
|
||||||
latestMessageTime: extraResultSettled.value.detail.latestMessageTime,
|
|
||||||
messageTables: Array.isArray(extraResultSettled.value.detail.messageTables) ? extraResultSettled.value.detail.messageTables : []
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (statsResultSettled.status === 'fulfilled' && statsResultSettled.value.success && statsResultSettled.value.data) {
|
|
||||||
const metric = statsResultSettled.value.data[normalizedSessionId]
|
|
||||||
if (metric) {
|
|
||||||
next = {
|
|
||||||
...next,
|
|
||||||
messageCount: Number.isFinite(metric.totalMessages) ? metric.totalMessages : next.messageCount,
|
|
||||||
voiceMessages: metric.voiceMessages,
|
|
||||||
imageMessages: metric.imageMessages,
|
|
||||||
videoMessages: metric.videoMessages,
|
|
||||||
emojiMessages: metric.emojiMessages,
|
|
||||||
privateMutualGroups: metric.privateMutualGroups,
|
|
||||||
groupMemberCount: metric.groupMemberCount,
|
|
||||||
groupMyMessages: metric.groupMyMessages,
|
|
||||||
groupActiveSpeakers: metric.groupActiveSpeakers,
|
|
||||||
groupMutualFriends: metric.groupMutualFriends,
|
|
||||||
firstMessageTime: Number.isFinite(metric.firstTimestamp) ? metric.firstTimestamp : next.firstMessageTime,
|
|
||||||
latestMessageTime: Number.isFinite(metric.lastTimestamp) ? metric.lastTimestamp : next.latestMessageTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldRefreshStats) {
|
||||||
|
setIsRefreshingSessionDetailStats(true)
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const freshResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: refreshIncludeRelations, forceRefresh: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
if (freshResult.success && freshResult.data) {
|
||||||
|
const metric = freshResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
const cacheMeta = freshResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
if (metric) {
|
||||||
|
applySessionDetailStats(
|
||||||
|
normalizedSessionId,
|
||||||
|
metric,
|
||||||
|
cacheMeta,
|
||||||
|
refreshIncludeRelations ? true : undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出页刷新会话统计失败:', error)
|
||||||
|
} finally {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsRefreshingSessionDetailStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导出页加载会话详情补充统计失败:', error)
|
console.error('导出页加载会话详情补充统计失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2655,7 +2776,77 @@ function ExportPage() {
|
|||||||
setIsLoadingSessionDetailExtra(false)
|
setIsLoadingSessionDetailExtra(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [contactByUsername, sessionRowByUsername])
|
}, [applySessionDetailStats, contactByUsername, sessionRowByUsername])
|
||||||
|
|
||||||
|
const loadSessionRelationStats = useCallback(async () => {
|
||||||
|
const normalizedSessionId = String(sessionDetail?.wxid || '').trim()
|
||||||
|
if (!normalizedSessionId || isLoadingSessionRelationStats) return
|
||||||
|
|
||||||
|
const requestSeq = detailRequestSeqRef.current
|
||||||
|
setIsLoadingSessionRelationStats(true)
|
||||||
|
try {
|
||||||
|
const relationResult = await window.electronAPI.chat.getExportSessionStats(
|
||||||
|
[normalizedSessionId],
|
||||||
|
{ includeRelations: true, allowStaleCache: true }
|
||||||
|
)
|
||||||
|
if (requestSeq !== detailRequestSeqRef.current) return
|
||||||
|
|
||||||
|
const metric = relationResult.success && relationResult.data
|
||||||
|
? relationResult.data[normalizedSessionId] as SessionExportMetric | undefined
|
||||||
|
: undefined
|
||||||
|
const cacheMeta = relationResult.success
|
||||||
|
? relationResult.cache?.[normalizedSessionId] as SessionExportCacheMeta | undefined
|
||||||
|
: undefined
|
||||||
|
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 }
|
||||||
|
)
|
||||||
|
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 {
|
||||||
|
if (requestSeq === detailRequestSeqRef.current) {
|
||||||
|
setIsLoadingSessionRelationStats(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [applySessionDetailStats, isLoadingSessionRelationStats, sessionDetail?.wxid])
|
||||||
|
|
||||||
|
const closeSessionDetailPanel = useCallback(() => {
|
||||||
|
detailRequestSeqRef.current += 1
|
||||||
|
setShowSessionDetailPanel(false)
|
||||||
|
setIsLoadingSessionDetail(false)
|
||||||
|
setIsLoadingSessionDetailExtra(false)
|
||||||
|
setIsRefreshingSessionDetailStats(false)
|
||||||
|
setIsLoadingSessionRelationStats(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const openSessionDetail = useCallback((sessionId: string) => {
|
const openSessionDetail = useCallback((sessionId: string) => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
@@ -2667,12 +2858,12 @@ function ExportPage() {
|
|||||||
if (!showSessionDetailPanel) return
|
if (!showSessionDetailPanel) return
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setShowSessionDetailPanel(false)
|
closeSessionDetailPanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [showSessionDetailPanel])
|
}, [closeSessionDetailPanel, showSessionDetailPanel])
|
||||||
|
|
||||||
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
|
const handleCopyDetailField = useCallback(async (text: string, field: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -3421,7 +3612,7 @@ function ExportPage() {
|
|||||||
{showSessionDetailPanel && (
|
{showSessionDetailPanel && (
|
||||||
<div
|
<div
|
||||||
className="export-session-detail-overlay"
|
className="export-session-detail-overlay"
|
||||||
onClick={() => setShowSessionDetailPanel(false)}
|
onClick={closeSessionDetailPanel}
|
||||||
>
|
>
|
||||||
<aside
|
<aside
|
||||||
className="export-session-detail-panel"
|
className="export-session-detail-panel"
|
||||||
@@ -3444,7 +3635,7 @@ function ExportPage() {
|
|||||||
<div className="detail-header-id">{sessionDetail?.wxid || ''}</div>
|
<div className="detail-header-id">{sessionDetail?.wxid || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="close-btn" onClick={() => setShowSessionDetailPanel(false)}>
|
<button className="close-btn" onClick={closeSessionDetailPanel}>
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -3498,6 +3689,13 @@ function ExportPage() {
|
|||||||
<MessageSquare size={14} />
|
<MessageSquare size={14} />
|
||||||
<span>消息统计(导出口径)</span>
|
<span>消息统计(导出口径)</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="detail-stats-meta">
|
||||||
|
{isRefreshingSessionDetailStats
|
||||||
|
? '统计刷新中...'
|
||||||
|
: sessionDetail.statsUpdatedAt
|
||||||
|
? `${sessionDetail.statsStale ? '缓存于' : '更新于'} ${formatYmdHmDateTime(sessionDetail.statsUpdatedAt)}${sessionDetail.statsStale ? '(将后台刷新)' : ''}`
|
||||||
|
: (isLoadingSessionDetailExtra ? '统计加载中...' : '暂无统计缓存')}
|
||||||
|
</div>
|
||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">消息总数</span>
|
<span className="label">消息总数</span>
|
||||||
<span className="value highlight">
|
<span className="value highlight">
|
||||||
@@ -3567,9 +3765,19 @@ function ExportPage() {
|
|||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">群共同好友数</span>
|
<span className="label">群共同好友数</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{Number.isFinite(sessionDetail.groupMutualFriends)
|
{sessionDetail.relationStatsLoaded
|
||||||
|
? (Number.isFinite(sessionDetail.groupMutualFriends)
|
||||||
? (sessionDetail.groupMutualFriends as number).toLocaleString()
|
? (sessionDetail.groupMutualFriends as number).toLocaleString()
|
||||||
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
: '—')
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="detail-inline-btn"
|
||||||
|
onClick={() => { void loadSessionRelationStats() }}
|
||||||
|
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
|
||||||
|
>
|
||||||
|
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -3577,9 +3785,19 @@ function ExportPage() {
|
|||||||
<div className="detail-item">
|
<div className="detail-item">
|
||||||
<span className="label">共同群聊数</span>
|
<span className="label">共同群聊数</span>
|
||||||
<span className="value">
|
<span className="value">
|
||||||
{Number.isFinite(sessionDetail.privateMutualGroups)
|
{sessionDetail.relationStatsLoaded
|
||||||
|
? (Number.isFinite(sessionDetail.privateMutualGroups)
|
||||||
? (sessionDetail.privateMutualGroups as number).toLocaleString()
|
? (sessionDetail.privateMutualGroups as number).toLocaleString()
|
||||||
: (isLoadingSessionDetailExtra ? '统计中...' : '—')}
|
: '—')
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
className="detail-inline-btn"
|
||||||
|
onClick={() => { void loadSessionRelationStats() }}
|
||||||
|
disabled={isLoadingSessionRelationStats || isLoadingSessionDetailExtra}
|
||||||
|
>
|
||||||
|
{isLoadingSessionRelationStats ? '加载中...' : '点击加载'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -449,10 +449,10 @@ export default function SnsPage() {
|
|||||||
const snsPostCountMap = new Map<string, number>(
|
const snsPostCountMap = new Map<string, number>(
|
||||||
Object.entries(snsCountsResult.data).map(([username, count]) => [username, Math.max(0, Number(count || 0))])
|
Object.entries(snsCountsResult.data).map(([username, count]) => [username, Math.max(0, Number(count || 0))])
|
||||||
)
|
)
|
||||||
const contactsWithCounts = contactsList.map(contact => ({
|
const contactsWithCounts: Contact[] = contactsList.map(contact => ({
|
||||||
...contact,
|
...contact,
|
||||||
postCount: snsPostCountMap.get(contact.username) ?? 0,
|
postCount: snsPostCountMap.get(contact.username) ?? 0,
|
||||||
postCountStatus: 'ready'
|
postCountStatus: 'ready' as const
|
||||||
}))
|
}))
|
||||||
setContacts(contactsWithCounts)
|
setContacts(contactsWithCounts)
|
||||||
|
|
||||||
|
|||||||
76
src/types/electron.d.ts
vendored
76
src/types/electron.d.ts
vendored
@@ -48,9 +48,18 @@ export interface ElectronAPI {
|
|||||||
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
onDownloadProgress: (callback: (progress: number) => void) => () => void
|
||||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
|
notification: {
|
||||||
|
show: (data: { title: string; content: string; avatarUrl?: string; sessionId: string }) => Promise<{ success?: boolean; error?: string } | void>
|
||||||
|
close: () => Promise<void>
|
||||||
|
click: (sessionId: string) => void
|
||||||
|
ready: () => void
|
||||||
|
resize: (width: number, height: number) => void
|
||||||
|
onShow: (callback: (event: any, data: any) => void) => () => void
|
||||||
|
}
|
||||||
log: {
|
log: {
|
||||||
getPath: () => Promise<string>
|
getPath: () => Promise<string>
|
||||||
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
read: () => Promise<{ success: boolean; content?: string; error?: string }>
|
||||||
|
debug: (data: any) => void
|
||||||
}
|
}
|
||||||
dbPath: {
|
dbPath: {
|
||||||
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
@@ -125,6 +134,11 @@ export interface ElectronAPI {
|
|||||||
messages?: Message[]
|
messages?: Message[]
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getCachedMessages: (sessionId: string) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
messages?: Message[]
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getContact: (username: string) => Promise<Contact | null>
|
getContact: (username: string) => Promise<Contact | null>
|
||||||
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
|
||||||
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
updateMessage: (sessionId: string, localId: number, createTime: number, newContent: string) => Promise<{ success: boolean; error?: string }>
|
||||||
@@ -176,7 +190,10 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getExportSessionStats: (sessionIds: string[]) => Promise<{
|
getExportSessionStats: (
|
||||||
|
sessionIds: string[],
|
||||||
|
options?: { includeRelations?: boolean; forceRefresh?: boolean; allowStaleCache?: boolean }
|
||||||
|
) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Record<string, {
|
data?: Record<string, {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -192,6 +209,13 @@ export interface ElectronAPI {
|
|||||||
groupActiveSpeakers?: number
|
groupActiveSpeakers?: number
|
||||||
groupMutualFriends?: number
|
groupMutualFriends?: number
|
||||||
}>
|
}>
|
||||||
|
cache?: Record<string, {
|
||||||
|
updatedAt: number
|
||||||
|
stale: boolean
|
||||||
|
includeRelations: boolean
|
||||||
|
source: 'memory' | 'disk' | 'fresh'
|
||||||
|
}>
|
||||||
|
needsRefresh?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
@@ -211,7 +235,7 @@ export interface ElectronAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; liveVideoPath?: string; error?: string }>
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
|
||||||
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
|
||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||||
@@ -327,6 +351,24 @@ export interface ElectronAPI {
|
|||||||
}>
|
}>
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
|
getGroupMembersPanelData: (chatroomId: string, forceRefresh?: boolean) => Promise<{
|
||||||
|
success: boolean
|
||||||
|
data?: Array<{
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
alias?: string
|
||||||
|
remark?: string
|
||||||
|
groupNickname?: string
|
||||||
|
isOwner?: boolean
|
||||||
|
isFriend: boolean
|
||||||
|
messageCount: number
|
||||||
|
}>
|
||||||
|
fromCache?: boolean
|
||||||
|
updatedAt?: number
|
||||||
|
error?: string
|
||||||
|
}>
|
||||||
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
|
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
data?: Array<{
|
data?: Array<{
|
||||||
@@ -443,6 +485,20 @@ export interface ElectronAPI {
|
|||||||
phrase: string
|
phrase: string
|
||||||
count: number
|
count: number
|
||||||
}>
|
}>
|
||||||
|
snsStats?: {
|
||||||
|
totalPosts: number
|
||||||
|
typeCounts?: Record<string, number>
|
||||||
|
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
|
||||||
|
}
|
||||||
|
lostFriend: {
|
||||||
|
username: string
|
||||||
|
displayName: string
|
||||||
|
avatarUrl?: string
|
||||||
|
earlyCount: number
|
||||||
|
lateCount: number
|
||||||
|
periodDesc: string
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -498,15 +554,26 @@ export interface ElectronAPI {
|
|||||||
myTopEmojiMd5?: string
|
myTopEmojiMd5?: string
|
||||||
friendTopEmojiMd5?: string
|
friendTopEmojiMd5?: string
|
||||||
myTopEmojiUrl?: string
|
myTopEmojiUrl?: string
|
||||||
|
friendTopEmojiUrl?: string
|
||||||
|
myTopEmojiCount?: number
|
||||||
|
friendTopEmojiCount?: number
|
||||||
topPhrases: Array<{ phrase: string; count: number }>
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
heatmap?: number[][]
|
heatmap?: number[][]
|
||||||
initiative?: { initiated: number; received: number }
|
initiative?: { initiated: number; received: number }
|
||||||
response?: { avg: number; fastest: number; count: number }
|
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||||
monthly?: Record<string, number>
|
monthly?: Record<string, number>
|
||||||
streak?: { days: number; startDate: string; endDate: string }
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
|
topPhrases: Array<{ phrase: string; count: number }>
|
||||||
|
myExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
friendExclusivePhrases: Array<{ phrase: string; count: number }>
|
||||||
|
heatmap?: number[][]
|
||||||
|
initiative?: { initiated: number; received: number }
|
||||||
|
response?: { avg: number; fastest: number; slowest?: number; count: number }
|
||||||
|
monthly?: Record<string, number>
|
||||||
|
streak?: { days: number; startDate: string; endDate: string }
|
||||||
}
|
}
|
||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
@@ -585,7 +652,7 @@ export interface ElectronAPI {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
|
||||||
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
|
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; videoPath?: string; error?: string }>
|
||||||
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
|
||||||
exportTimeline: (options: {
|
exportTimeline: (options: {
|
||||||
outputDir: string
|
outputDir: string
|
||||||
@@ -618,6 +685,7 @@ export interface ElectronAPI {
|
|||||||
|
|
||||||
export interface ExportOptions {
|
export interface ExportOptions {
|
||||||
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'arkme-json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
|
||||||
|
contentType?: 'text' | 'voice' | 'image' | 'video' | 'emoji'
|
||||||
dateRange?: { start: number; end: number } | null
|
dateRange?: { start: number; end: number } | null
|
||||||
senderUsername?: string
|
senderUsername?: string
|
||||||
fileNameSuffix?: string
|
fileNameSuffix?: string
|
||||||
|
|||||||
21
src/vite-env.d.ts
vendored
21
src/vite-env.d.ts
vendored
@@ -1,22 +1 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface Window {
|
|
||||||
electronAPI: {
|
|
||||||
// ... other methods ...
|
|
||||||
auth: {
|
|
||||||
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
verifyEnabled: () => Promise<boolean>
|
|
||||||
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
|
|
||||||
setHelloSecret: (password: string) => Promise<{ success: boolean }>
|
|
||||||
clearHelloSecret: () => Promise<{ success: boolean }>
|
|
||||||
isLockMode: () => Promise<boolean>
|
|
||||||
}
|
|
||||||
// For brevity, using 'any' for other parts or properly importing types if available.
|
|
||||||
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
|
|
||||||
// or import a shared type definition.
|
|
||||||
[key: string]: any
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user