Merge pull request #36 from hicccc77/dev

Dev
This commit is contained in:
Forrest
2026-01-15 00:34:51 +08:00
committed by GitHub
36 changed files with 3119 additions and 1587 deletions

7
.gitignore vendored
View File

@@ -13,6 +13,7 @@ dist
dist-electron
dist-ssr
*.local
test/
# Editor directories and files
.vscode/*
@@ -42,6 +43,10 @@ release
# OS
Thumbs.db
# Electron dev cache
.electron/
.cache/
# 忽略 Visual Studio 临时文件夹
@@ -50,4 +55,4 @@ Thumbs.db
*.ipch
*.aps
wcdb/
wcdb/

View File

@@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean {
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
}
function isHdDat(fileName: string): boolean {
const lower = fileName.toLowerCase()
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
return base.endsWith('_hd') || base.endsWith('_h')
}
function walkForDat(
root: string,
datName: string,
@@ -101,6 +107,8 @@ function walkForDat(
if (!isLikelyImageDatBase(baseLower)) continue
if (!hasXVariant(baseLower)) continue
if (!matchesDatName(lower, datName)) continue
// 排除高清图片格式 (_hd, _h)
if (isHdDat(lower)) continue
matchedBases.add(baseLower)
const isThumb = isThumbnailDat(lower)
if (!allowThumbnail && isThumb) continue

View File

@@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService'
import { exportService, ExportOptions } from './services/exportService'
import { KeyService } from './services/keyService'
// 配置自动更新
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
@@ -381,6 +382,8 @@ function registerIpcHandlers() {
return true
})
// 聊天相关
ipcMain.handle('chat:connect', async () => {
return chatService.connect()
@@ -410,6 +413,10 @@ function registerIpcHandlers() {
return chatService.getContactAvatar(username)
})
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
return chatService.getCachedSessionMessages(sessionId)
})
ipcMain.handle('chat:getMyAvatarUrl', async () => {
return chatService.getMyAvatarUrl()
})
@@ -439,6 +446,9 @@ function registerIpcHandlers() {
return chatService.getMessageById(sessionId, localId)
})
// 私聊克隆
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
return imageDecryptService.decryptImage(payload)
})
@@ -460,8 +470,8 @@ function registerIpcHandlers() {
})
// 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async () => {
return analyticsService.getOverallStatistics()
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
return analyticsService.getOverallStatistics(force)
})
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
@@ -675,9 +685,11 @@ function checkForUpdatesOnStartup() {
app.whenReady().then(() => {
configService = new ConfigService()
const resourcesPath = app.isPackaged
const candidateResources = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const fallbackResources = join(process.cwd(), 'resources')
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
const userDataPath = app.getPath('userData')
wcdbService.setPaths(resourcesPath, userDataPath)
wcdbService.setLogEnabled(configService.get('logEnabled') === true)

View File

@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close')
close: () => ipcRenderer.invoke('wcdb:close'),
},
// 密钥获取
@@ -101,12 +102,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
close: () => ipcRenderer.invoke('chat:close'),
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
},
// 图片解密
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>

View File

@@ -1,5 +1,8 @@
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile } from 'fs/promises'
import { app } from 'electron'
export interface ChatStatistics {
totalMessages: number
@@ -253,15 +256,31 @@ class AnalyticsService {
sessionIds: string[],
beginTimestamp = 0,
endTimestamp = 0,
window?: any
window?: any,
force = false
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (force) {
if (this.aggregateCache) this.aggregateCache = null
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
}
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' }
}
}
// 尝试从文件加载缓存
if (!force) {
const fileCache = await this.loadCacheFromFile()
if (fileCache && fileCache.key === cacheKey) {
this.aggregateCache = fileCache
return { success: true, data: fileCache.data, source: 'file-cache' }
}
}
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise
}
@@ -291,7 +310,12 @@ class AnalyticsService {
this.aggregatePromise = { key: cacheKey, promise }
try {
return await promise
const result = await promise
// 如果计算成功,同时写入此文件缓存
if (result.success && result.data && result.source !== 'cache') {
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
}
return result
} finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null
@@ -299,6 +323,25 @@ class AnalyticsService {
}
}
private getCacheFilePath(): string {
return join(app.getPath('userData'), 'analytics_cache.json')
}
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
try {
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
return JSON.parse(raw)
} catch { return null }
}
private async saveCacheToFile(data: any) {
try {
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
} catch (e) {
console.error('保存统计缓存失败:', e)
}
}
private normalizeAggregateSessions(
sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined
@@ -326,7 +369,7 @@ class AnalyticsService {
void results
}
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
@@ -340,7 +383,7 @@ class AnalyticsService {
const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' }
@@ -458,8 +501,8 @@ class AnalyticsService {
const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
// 前端期望 1=Mon...7=Sun
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
// 前端期望 1=周一...7=周日
const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10)

View File

@@ -14,6 +14,8 @@ import { app } from 'electron'
const execFileAsync = promisify(execFile)
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
type HardlinkState = {
db: Database.Database
@@ -56,6 +58,7 @@ export interface Message {
aesKey?: string
encrypVer?: number
cdnThumbUrl?: string
voiceDurationSeconds?: number
}
export interface Contact {
@@ -74,13 +77,19 @@ class ChatService {
private connected = false
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
private readonly messageBatchDefault = 50
private avatarCache: Map<string, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService
constructor() {
this.configService = new ConfigService()
this.contactCacheService = new ContactCacheService(this.configService.get('cachePath'))
const persisted = this.contactCacheService.getAllEntries()
this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.get('cachePath'))
}
/**
@@ -231,7 +240,7 @@ class ChatService {
let displayName = username
let avatarUrl: string | undefined = undefined
const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
if (cached) {
displayName = cached.displayName || username
avatarUrl = cached.avatarUrl
}
@@ -279,6 +288,7 @@ class ChatService {
const now = Date.now()
const missing: string[] = []
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
const updatedEntries: Record<string, ContactCacheEntry> = {}
// 检查缓存
for (const username of usernames) {
@@ -304,17 +314,20 @@ class ChatService {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
result[username] = { displayName, avatarUrl }
// 更新缓存
this.avatarCache.set(username, {
const cacheEntry: ContactCacheEntry = {
displayName: displayName || username,
avatarUrl,
updatedAt: now
})
}
result[username] = { displayName, avatarUrl }
// 更新缓存并记录持久化
this.avatarCache.set(username, cacheEntry)
updatedEntries[username] = cacheEntry
}
if (Object.keys(updatedEntries).length > 0) {
this.contactCacheService.setEntries(updatedEntries)
}
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 补充联系人信息失败:', e)
@@ -456,6 +469,7 @@ class ChatService {
}
state.fetched += rows.length
this.messageCacheService.set(sessionId, normalized)
return { success: true, messages: normalized, hasMore }
} catch (e) {
console.error('ChatService: 获取消息失败:', e)
@@ -463,6 +477,20 @@ class ChatService {
}
}
async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
try {
if (!sessionId) return { success: true, messages: [] }
const entry = this.messageCacheService.get(sessionId)
if (!entry || !Array.isArray(entry.messages)) {
return { success: true, messages: [] }
}
return { success: true, messages: entry.messages.slice() }
} catch (error) {
console.error('ChatService: 获取缓存消息失败:', error)
return { success: false, error: String(error) }
}
}
/**
* 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL
*/
@@ -639,24 +667,24 @@ class ChatService {
const messages: Message[] = []
for (const row of rows) {
const content = this.decodeMessageContent(
this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]),
this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
])
)
const rawMessageContent = this.getRowField(row, [
'message_content',
'messageContent',
'content',
'msg_content',
'msgContent',
'WCDB_CT_message_content',
'WCDB_CT_messageContent'
]);
const rawCompressContent = this.getRowField(row, [
'compress_content',
'compressContent',
'compressed_content',
'WCDB_CT_compress_content',
'WCDB_CT_compressContent'
]);
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
@@ -668,6 +696,16 @@ class ChatService {
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0
if (isSend === null) {
isSend = expectedIsSend
// [DEBUG] Issue #34: 记录 isSend 推断过程
if (expectedIsSend === 0 && localType === 1) {
// 仅在被判为接收且是文本消息时记录,避免刷屏
// console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`)
}
}
} else if (senderUsername && !myWxid) {
// [DEBUG] Issue #34: 未配置 myWxid无法判断是否发送
if (messages.length < 5) {
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
}
}
@@ -1453,9 +1491,9 @@ class ChatService {
*/
private decodeMessageContent(messageContent: any, compressContent: any): string {
// 优先使用 compress_content
let content = this.decodeMaybeCompressed(compressContent)
let content = this.decodeMaybeCompressed(compressContent, 'compress_content')
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
content = this.decodeMaybeCompressed(messageContent, 'message_content')
}
return content
}
@@ -1463,12 +1501,14 @@ class ChatService {
/**
* 尝试解码可能压缩的内容
*/
private decodeMaybeCompressed(raw: any): string {
private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string {
if (!raw) return ''
// console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw)
// 如果是 Buffer/Uint8Array
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return this.decodeBinaryContent(Buffer.from(raw))
return this.decodeBinaryContent(Buffer.from(raw), String(raw))
}
// 如果是字符串
@@ -1479,7 +1519,9 @@ class ChatService {
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) {
return this.decodeBinaryContent(bytes)
const result = this.decodeBinaryContent(bytes, raw)
// console.log(`[ChatService] HEX decoded result: ${result}`)
return result
}
}
@@ -1487,7 +1529,7 @@ class ChatService {
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
return this.decodeBinaryContent(bytes, raw)
} catch { }
}
@@ -1501,7 +1543,7 @@ class ChatService {
/**
* 解码二进制内容(处理 zstd 压缩)
*/
private decodeBinaryContent(data: Buffer): string {
private decodeBinaryContent(data: Buffer, fallbackValue?: string): string {
if (data.length === 0) return ''
try {
@@ -1528,10 +1570,16 @@ class ChatService {
return decoded.replace(/\uFFFD/g, '')
}
// 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue
if (fallbackValue && replacementCount > 0) {
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`)
return fallbackValue
}
// 尝试 latin1 解码
return data.toString('latin1')
} catch {
return ''
return fallbackValue || ''
}
}
@@ -1610,7 +1658,13 @@ class ChatService {
const avatarResult = await wcdbService.getAvatarUrls([username])
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() })
const cacheEntry: ContactCacheEntry = {
avatarUrl,
displayName,
updatedAt: Date.now()
}
this.avatarCache.set(username, cacheEntry)
this.contactCacheService.setEntries({ [username]: cacheEntry })
return { avatarUrl, displayName }
} catch {
return null
@@ -1633,11 +1687,24 @@ class ChatService {
}
const cleanedWxid = this.cleanAccountDirName(myWxid)
const result = await wcdbService.getAvatarUrls([myWxid, cleanedWxid])
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
const result = await wcdbService.getAvatarUrls(fetchList)
if (result.success && result.map) {
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid]
return { success: true, avatarUrl }
// 按优先级尝试匹配
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
if (avatarUrl) {
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
return { success: true, avatarUrl }
}
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
return { success: true, avatarUrl: undefined }
}
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
return { success: true, avatarUrl: undefined }
} catch (e) {
console.error('ChatService: 获取当前用户头像失败:', e)
@@ -2462,12 +2529,12 @@ class ChatService {
}
const aesData = payload.subarray(0, alignedAesSize)
let unpadded = Buffer.alloc(0)
let unpadded: Buffer = Buffer.alloc(0)
if (aesData.length > 0) {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
unpadded = this.strictRemovePadding(decrypted)
unpadded = this.strictRemovePadding(decrypted) as Buffer
}
const remaining = payload.subarray(alignedAesSize)
@@ -2475,21 +2542,21 @@ class ChatService {
throw new Error('文件格式异常XOR 数据长度不合法')
}
let rawData = Buffer.alloc(0)
let xoredData = Buffer.alloc(0)
let rawData: Buffer = Buffer.alloc(0)
let xoredData: Buffer = Buffer.alloc(0)
if (xorSize > 0) {
const rawLength = remaining.length - xorSize
if (rawLength < 0) {
throw new Error('文件格式异常原始数据长度小于XOR长度')
}
rawData = remaining.subarray(0, rawLength)
rawData = remaining.subarray(0, rawLength) as Buffer
const xorData = remaining.subarray(rawLength)
xoredData = Buffer.alloc(xorData.length)
for (let i = 0; i < xorData.length; i++) {
xoredData[i] = xorData[i] ^ xorKey
}
} else {
rawData = remaining
rawData = remaining as Buffer
xoredData = Buffer.alloc(0)
}

View File

@@ -19,6 +19,7 @@ interface ConfigSchema {
themeId: string
language: string
logEnabled: boolean
llmModelPath: string
}
export class ConfigService {
@@ -40,7 +41,8 @@ export class ConfigService {
theme: 'system',
themeId: 'cloud-dancer',
language: 'zh-CN',
logEnabled: false
logEnabled: false,
llmModelPath: ''
}
})
}

View File

@@ -0,0 +1,75 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { app } from 'electron'
export interface ContactCacheEntry {
displayName?: string
avatarUrl?: string
updatedAt: number
}
export class ContactCacheService {
private readonly cacheFilePath: string
private cache: Record<string, ContactCacheEntry> = {}
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache')
this.cacheFilePath = join(basePath, 'contacts.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
}
} catch (error) {
console.error('ContactCacheService: 载入缓存失败', error)
this.cache = {}
}
}
get(username: string): ContactCacheEntry | undefined {
return this.cache[username]
}
getAllEntries(): Record<string, ContactCacheEntry> {
return { ...this.cache }
}
setEntries(entries: Record<string, ContactCacheEntry>): void {
if (Object.keys(entries).length === 0) return
let changed = false
for (const [username, entry] of Object.entries(entries)) {
const existing = this.cache[username]
if (!existing || entry.updatedAt >= existing.updatedAt) {
this.cache[username] = entry
changed = true
}
}
if (changed) {
this.persist()
}
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('ContactCacheService: 保存缓存失败', error)
}
}
}

View File

@@ -19,6 +19,7 @@ interface ChatLabMeta {
platform: string
type: 'group' | 'private'
groupId?: string
groupAvatar?: string
}
interface ChatLabMember {
@@ -425,6 +426,81 @@ class ExportService {
return { rows, memberSet, firstTime, lastTime }
}
// 补齐群成员,避免只导出发言者导致头像缺失
private async mergeGroupMembers(
chatroomId: string,
memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>,
includeAvatars: boolean
): Promise<void> {
const result = await wcdbService.getGroupMembers(chatroomId)
if (!result.success || !result.members || result.members.length === 0) return
const rawMembers = result.members as Array<{
username?: string
avatarUrl?: string
nickname?: string
displayName?: string
remark?: string
originalName?: string
}>
const usernames = rawMembers
.map((member) => member.username)
.filter((username): username is string => Boolean(username))
if (usernames.length === 0) return
const lookupUsernames = new Set<string>()
for (const username of usernames) {
lookupUsernames.add(username)
const cleaned = this.cleanAccountDirName(username)
if (cleaned && cleaned !== username) {
lookupUsernames.add(cleaned)
}
}
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(Array.from(lookupUsernames)),
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
])
for (const member of rawMembers) {
const username = member.username
if (!username) continue
const cleaned = this.cleanAccountDirName(username)
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username)
: username
const groupNickname = member.nickname || member.displayName || member.remark || member.originalName
const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map
? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl)
: member.avatarUrl
const existing = memberSet.get(username)
if (existing) {
if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) {
existing.member.accountName = displayName
}
if (groupNickname && !existing.member.groupNickname) {
existing.member.groupNickname = groupNickname
}
if (!existing.avatarUrl && avatarUrl) {
existing.avatarUrl = avatarUrl
}
memberSet.set(username, existing)
continue
}
const chatlabMember: ChatLabMember = {
platformId: username,
accountName: displayName
}
if (groupNickname) {
chatlabMember.groupNickname = groupNickname
}
memberSet.set(username, { member: chatlabMember, avatarUrl })
}
}
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
if (!avatarUrl) return null
if (avatarUrl.startsWith('data:')) {
@@ -514,7 +590,11 @@ class ExportService {
}
}
if (!data) continue
const finalMime = mime || this.inferImageMime(fileInfo.ext)
// 优先使用内容检测出的 MIME 类型
const detectedMime = this.detectMimeType(data)
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
const base64 = data.toString('base64')
result.set(member.username, `data:${finalMime};base64,${base64}`)
} catch {
@@ -525,6 +605,39 @@ class ExportService {
return result
}
private detectMimeType(buffer: Buffer): string | null {
if (buffer.length < 4) return null
// PNG: 89 50 4E 47
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
return 'image/png'
}
// JPEG: FF D8 FF
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
return 'image/jpeg'
}
// GIF: 47 49 46 38
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
return 'image/gif'
}
// WEBP: RIFF ... WEBP
if (buffer.length >= 12 &&
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return 'image/webp'
}
// BMP: 42 4D
if (buffer[0] === 0x42 && buffer[1] === 0x4D) {
return 'image/bmp'
}
return null
}
private inferImageMime(ext: string): string {
switch (ext.toLowerCase()) {
case '.png':
@@ -567,6 +680,9 @@ class ExportService {
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
allMessages.sort((a, b) => a.createTime - b.createTime)
@@ -580,11 +696,13 @@ class ExportService {
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername,
accountName: msg.senderUsername
accountName: msg.senderUsername,
groupNickname: undefined
}
return {
sender: msg.senderUsername,
accountName: memberInfo.accountName,
groupNickname: memberInfo.groupNickname,
timestamp: msg.createTime,
type: this.convertMessageType(msg.localType, msg.content),
content: this.parseMessageContent(msg.content, msg.localType)
@@ -603,6 +721,7 @@ class ExportService {
)
: new Map<string, string>()
const sessionAvatar = avatarMap.get(sessionId)
const members = Array.from(collected.memberSet.values()).map((info) => {
const avatar = avatarMap.get(info.member.platformId)
return avatar ? { ...info.member, avatar } : info.member
@@ -618,7 +737,8 @@ class ExportService {
name: sessionInfo.displayName,
platform: 'wechat',
type: isGroup ? 'group' : 'private',
...(isGroup && { groupId: sessionId })
...(isGroup && { groupId: sessionId }),
...(sessionAvatar && { groupAvatar: sessionAvatar })
},
members,
messages: chatLabMessages
@@ -729,7 +849,8 @@ class ExportService {
displayName: sessionInfo.displayName,
type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime,
messageCount: allMessages.length
messageCount: allMessages.length,
avatar: undefined as string | undefined
},
messages: allMessages
}

View File

@@ -181,6 +181,8 @@ class GroupAnalyticsService {
}
}
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
try {
const conn = await this.ensureConnected()

View File

@@ -1,14 +1,14 @@
import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
import { writeFile } from 'fs/promises'
import crypto from 'crypto'
import { Worker } from 'worker_threads'
import { ConfigService } from './config'
import { wcdbService } from './wcdbService'
type DecryptResult = {
type DecryptResult = {
success: boolean
localPath?: string
error?: string
@@ -32,11 +32,45 @@ export class ImageDecryptService {
private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
// 同时输出到控制台
if (meta) {
console.info(message, meta)
} else {
console.info(message)
}
// 写入日志文件
this.writeLog(logLine)
}
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return
const timestamp = new Date().toISOString()
const errorStr = error ? ` Error: ${String(error)}` : ''
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
// 同时输出到控制台
console.error(message, error, meta)
// 写入日志文件
this.writeLog(logLine)
}
private writeLog(line: string): void {
try {
const logDir = join(app.getPath('userData'), 'logs')
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true })
}
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
} catch (err) {
console.error('写入日志失败:', err)
}
}
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
@@ -49,6 +83,7 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) {
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
const dataUrl = this.fileToDataUrl(cached)
const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
@@ -68,6 +103,7 @@ export class ImageDecryptService {
for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) {
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing)
const isThumb = this.isThumbnailPath(existing)
@@ -81,6 +117,7 @@ export class ImageDecryptService {
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
}
}
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' }
}
@@ -120,15 +157,18 @@ export class ImageDecryptService {
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
cacheKey: string
): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
try {
const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
return { success: false, error: '未配置账号或数据库路径' }
}
const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
return { success: false, error: '未找到账号目录' }
}
@@ -139,15 +179,19 @@ export class ImageDecryptService {
payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
)
// 如果要求高清图但没找到,直接返回提示
if (!datPath && payload.force) {
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
}
if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到图片文件' }
}
this.logInfo('找到DAT文件', { datPath })
if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath)
@@ -160,6 +204,7 @@ export class ImageDecryptService {
// 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) {
@@ -192,13 +237,15 @@ export class ImageDecryptService {
const aesKeyRaw = this.configService.get('imageAesKey')
const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const ext = this.detectImageExtension(decrypted) || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
if (!isThumb) {
@@ -209,6 +256,7 @@ export class ImageDecryptService {
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
} catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) }
}
}
@@ -233,7 +281,7 @@ export class ImageDecryptService {
if (this.isAccountDir(entryPath)) return entryPath
}
}
} catch {}
} catch { }
return null
}
@@ -244,10 +292,10 @@ export class ImageDecryptService {
private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid)
const cacheAccountDir = join(cachePath, cleanedWxid)
// 检查缓存目录下是否有 hardlink.db
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
return cacheAccountDir
@@ -312,7 +360,7 @@ export class ImageDecryptService {
allowThumbnail,
skipResolvedCache
})
// 优先通过 hardlink.db 查询
if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
@@ -474,7 +522,7 @@ export class ImageDecryptService {
if (!hasUpdate) return
this.updateFlags.set(cacheKey, true)
this.emitImageUpdate(payload, cacheKey)
}).catch(() => {})
}).catch(() => { })
}
private looksLikeMd5(value: string): boolean {
@@ -528,7 +576,7 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
return null
}
const dir1 = this.getRowValue(row, 'dir1')
const dir2 = this.getRowValue(row, 'dir2')
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
@@ -549,7 +597,7 @@ export class ImageDecryptService {
// dir1 和 dir2 是 rowid需要从 dir2id 表查询对应的目录名
let dir1Name: string | null = null
let dir2Name: string | null = null
if (state.dirTable) {
try {
// 通过 rowid 查询目录名
@@ -562,7 +610,7 @@ export class ImageDecryptService {
const value = this.getRowValue(dir1Result.rows[0], 'username')
if (value) dir1Name = String(value)
}
const dir2Result = await wcdbService.execQuery(
'media',
hardlinkPath,
@@ -588,14 +636,14 @@ export class ImageDecryptService {
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
]
for (const fullPath of possiblePaths) {
if (existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath
}
}
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
return null
} catch {
@@ -829,14 +877,14 @@ export class ImageDecryptService {
}
}
}
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
const imageDir = join(root, normalizedKey)
if (existsSync(imageDir)) {
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit
}
// 兼容旧的平铺结构
for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`)
@@ -846,7 +894,7 @@ export class ImageDecryptService {
const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate
}
return null
}
@@ -863,6 +911,8 @@ export class ImageDecryptService {
}
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
if (existsSync(thumbPath)) return thumbPath
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
if (!preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath
@@ -875,14 +925,14 @@ export class ImageDecryptService {
const name = basename(datPath)
const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
// 提取基础名称(去掉 _t, _h 等后缀)
const normalizedBase = this.normalizeDatBase(base)
// 判断是缩略图还是高清图
const isThumb = this.isThumbnailDat(lower)
const suffix = isThumb ? '_thumb' : '_hd'
const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath)
const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
@@ -960,8 +1010,9 @@ export class ImageDecryptService {
const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue
if (!this.hasXVariant(lower.slice(0, -4))) continue
const baseLower = lower.slice(0, -4)
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
if (!this.hasXVariant(baseLower)) continue
if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry)
}
@@ -973,6 +1024,7 @@ export class ImageDecryptService {
if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4)
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
return this.hasXVariant(baseLower)
}
@@ -1079,7 +1131,7 @@ export class ImageDecryptService {
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
const version = this.getDatVersion(datPath)
if (version === 0) {
return this.decryptDatV3(datPath, xorKey)
}
@@ -1136,7 +1188,7 @@ export class ImageDecryptService {
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) {
throw new Error('文件格式异常AES 数据长度超过文件实际长度')
}
@@ -1147,7 +1199,7 @@ export class ImageDecryptService {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
// 使用 PKCS7 填充移除
unpadded = this.strictRemovePadding(decrypted)
}
@@ -1214,7 +1266,7 @@ export class ImageDecryptService {
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return '.webp'
}
return null
@@ -1332,10 +1384,10 @@ export class ImageDecryptService {
keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++
}
} catch {}
} catch { }
}
}
} catch {}
} catch { }
}
scanDir(dirPath)

View File

@@ -1,9 +1,10 @@
import { app } from 'electron'
import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process'
import { promisify } from 'util'
import crypto from 'crypto'
import os from 'os'
const execFileAsync = promisify(execFile)
@@ -57,18 +58,94 @@ export class KeyService {
private readonly ERROR_SUCCESS = 0
private getDllPath(): string {
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
return join(resourcesPath, 'wx_key.dll')
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
// 候选路径列表
const candidates: string[] = []
// 1. 显式环境变量 (最高优先级)
if (process.env.WX_KEY_DLL_PATH) {
candidates.push(process.env.WX_KEY_DLL_PATH)
}
if (isPackaged) {
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else {
// 开发环境
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
}
// 检查并返回第一个存在的路径
for (const path of candidates) {
if (existsSync(path)) {
return path
}
}
// 如果都没找到,返回最可能的路径以便报错信息有参考
return candidates[0]
}
// 检查路径是否为 UNC 路径或网络路径
private isNetworkPath(path: string): boolean {
// UNC 路径以 \\ 开头
if (path.startsWith('\\\\')) {
return true
}
// 检查是否为网络映射驱动器简化检测A: 表示驱动器)
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
// 但对于大多数 VM 共享场景UNC 路径检测已足够
return false
}
// 将 DLL 复制到本地临时目录
private localizeNetworkDll(originalPath: string): string {
try {
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
const localPath = join(tempDir, 'wx_key.dll')
// 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath
}
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath
} catch (e) {
console.error('DLL 本地化失败:', e)
// 如果本地化失败,返回原路径
return originalPath
}
}
private ensureLoaded(): boolean {
if (this.initialized) return true
let dllPath = ''
try {
this.koffi = require('koffi')
const dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
dllPath = this.getDllPath()
if (!existsSync(dllPath)) {
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
return false
}
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath)
}
this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
@@ -80,7 +157,14 @@ export class KeyService {
this.initialized = true
return true
} catch (e) {
console.error('加载 wx_key.dll 失败:', e)
const errorMsg = e instanceof Error ? e.message : String(e)
const errorStack = e instanceof Error ? e.stack : ''
console.error(`加载 wx_key.dll 失败`)
console.error(` 路径: ${dllPath}`)
console.error(` 错误: ${errorMsg}`)
if (errorStack) {
console.error(` 堆栈: ${errorStack}`)
}
return false
}
}
@@ -831,17 +915,40 @@ export class KeyService {
return buffer.subarray(0, bytesRead[0])
}
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
private async getAesKeyFromMemory(
pid: number,
ciphertext: Buffer,
onProgress?: (current: number, total: number, message: string) => void
): Promise<string | null> {
if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null
try {
const regions = this.getMemoryRegions(hProcess)
const chunkSize = 4 * 1024 * 1024
const allRegions = this.getMemoryRegions(hProcess)
// 优化1: 只保留小内存区域(< 10MB- 密钥通常在小区域,可大幅减少扫描时间
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
// 优化3: 计算总字节数用于精确进度报告
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
let processedBytes = 0
// 优化4: 减小分块大小到 1MB参考 wx_key 项目)
const chunkSize = 1 * 1024 * 1024
const overlap = 65
for (const [baseAddress, regionSize] of regions) {
if (regionSize > 100 * 1024 * 1024) continue
let currentRegion = 0
for (const [baseAddress, regionSize] of sortedRegions) {
currentRegion++
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
// 每个区域都让出主线程确保UI流畅
await new Promise(resolve => setImmediate(resolve))
let offset = 0
let trailing: Buffer | null = null
while (offset < regionSize) {
@@ -896,6 +1003,9 @@ export class KeyService {
trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize
}
// 更新已处理字节数
processedBytes += regionSize
}
return null
} finally {
@@ -933,7 +1043,9 @@ export class KeyService {
if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.('正在扫描内存获取 AES 密钥...')
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
onProgress?.(`${msg} (${current}/${total})`)
})
if (!aesKey) {
return {
success: false,

View File

@@ -0,0 +1,68 @@
import { join, dirname } from 'path'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { app } from 'electron'
export interface SessionMessageCacheEntry {
updatedAt: number
messages: any[]
}
export class MessageCacheService {
private readonly cacheFilePath: string
private cache: Record<string, SessionMessageCacheEntry> = {}
private readonly sessionLimit = 150
constructor(cacheBasePath?: string) {
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
? cacheBasePath
: join(app.getPath('userData'), 'WeFlowCache')
this.cacheFilePath = join(basePath, 'session-messages.json')
this.ensureCacheDir()
this.loadCache()
}
private ensureCacheDir() {
const dir = dirname(this.cacheFilePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
}
private loadCache() {
if (!existsSync(this.cacheFilePath)) return
try {
const raw = readFileSync(this.cacheFilePath, 'utf8')
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
this.cache = parsed
}
} catch (error) {
console.error('MessageCacheService: 载入缓存失败', error)
this.cache = {}
}
}
get(sessionId: string): SessionMessageCacheEntry | undefined {
return this.cache[sessionId]
}
set(sessionId: string, messages: any[]): void {
if (!sessionId) return
const trimmed = messages.length > this.sessionLimit
? messages.slice(-this.sessionLimit)
: messages.slice()
this.cache[sessionId] = {
updatedAt: Date.now(),
messages: trimmed
}
this.persist()
}
private persist() {
try {
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
} catch (error) {
console.error('MessageCacheService: 保存缓存失败', error)
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

122
electron/wcdbWorker.ts Normal file
View File

@@ -0,0 +1,122 @@
import { parentPort, workerData } from 'worker_threads'
import { WcdbCore } from './services/wcdbCore'
const core = new WcdbCore()
if (parentPort) {
parentPort.on('message', async (msg) => {
const { id, type, payload } = msg
try {
let result: any
switch (type) {
case 'setPaths':
core.setPaths(payload.resourcesPath, payload.userDataPath)
result = { success: true }
break
case 'setLogEnabled':
core.setLogEnabled(payload.enabled)
result = { success: true }
break
case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'open':
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
break
case 'close':
core.close()
result = { success: true }
break
case 'isConnected':
result = core.isConnected()
break
case 'getSessions':
result = await core.getSessions()
break
case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break
case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId)
break
case 'getDisplayNames':
result = await core.getDisplayNames(payload.usernames)
break
case 'getAvatarUrls':
result = await core.getAvatarUrls(payload.usernames)
break
case 'getGroupMemberCount':
result = await core.getGroupMemberCount(payload.chatroomId)
break
case 'getGroupMemberCounts':
result = await core.getGroupMemberCounts(payload.chatroomIds)
break
case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId)
break
case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId)
break
case 'getMessageTableStats':
result = await core.getMessageTableStats(payload.sessionId)
break
case 'getMessageMeta':
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
break
case 'getContact':
result = await core.getContact(payload.username)
break
case 'getAggregateStats':
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAvailableYears':
result = await core.getAvailableYears(payload.sessionIds)
break
case 'getAnnualReportStats':
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
break
case 'getAnnualReportExtras':
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
break
case 'getGroupStats':
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
break
case 'openMessageCursor':
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'openMessageCursorLite':
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
break
case 'fetchMessageBatch':
result = await core.fetchMessageBatch(payload.cursor)
break
case 'closeMessageCursor':
result = await core.closeMessageCursor(payload.cursor)
break
case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql)
break
case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
break
case 'listMessageDbs':
result = await core.listMessageDbs()
break
case 'listMediaDbs':
result = await core.listMediaDbs()
break
case 'getMessageById':
result = await core.getMessageById(payload.sessionId, payload.localId)
break
default:
result = { success: false, error: `Unknown method: ${type}` }
}
parentPort!.postMessage({ id, result })
} catch (e) {
parentPort!.postMessage({ id, error: String(e) })
}
})
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "weflow",
"version": "1.0.0",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "weflow",
"version": "1.0.0",
"version": "1.0.4",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.5.0",

View File

@@ -1,16 +1,17 @@
{
"name": "weflow",
"version": "1.0.4",
"description": "WeFlow - 微信聊天记录查看工具",
"version": "1.1.0",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",
"scripts": {
"postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'",
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"electron:dev": "vite --mode electron",
"electron:build": "npm run build",
"postinstall": "electron-rebuild"
"electron:build": "npm run build"
},
"dependencies": {
"better-sqlite3": "^12.5.0",

Binary file not shown.

View File

@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage'
@@ -14,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
import DataManagementPage from './pages/DataManagementPage'
import SettingsPage from './pages/SettingsPage'
import ExportPage from './pages/ExportPage'
import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
import * as configService from './services/config'
@@ -188,7 +190,7 @@ function App() {
}
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect()
if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath)
@@ -307,7 +309,8 @@ function App() {
<Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} />

View File

@@ -0,0 +1,79 @@
.avatar-component {
position: relative;
display: inline-block;
overflow: hidden;
background-color: var(--bg-tertiary, #f5f5f5);
flex-shrink: 0;
border-radius: 4px;
/* Default radius */
&.circle {
border-radius: 50%;
}
&.rounded {
border-radius: 6px;
}
/* Image styling */
img.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease-in-out;
border-radius: inherit;
&.loaded {
opacity: 1;
}
&.instant {
transition: none !important;
opacity: 1 !important;
}
}
/* Placeholder/Letter styling */
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: var(--text-secondary, #666);
background-color: var(--bg-tertiary, #e0e0e0);
font-size: 1.2em;
text-transform: uppercase;
user-select: none;
border-radius: inherit;
}
/* Loading Skeleton */
.avatar-skeleton {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
var(--bg-tertiary, #f0f0f0) 25%,
var(--bg-secondary, #e0e0e0) 50%,
var(--bg-tertiary, #f0f0f0) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
z-index: 1;
border-radius: inherit;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
}

129
src/components/Avatar.tsx Normal file
View File

@@ -0,0 +1,129 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { User } from 'lucide-react'
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import './Avatar.scss'
// 全局缓存已成功加载过的头像 URL用于控制后续是否显示动画
const loadedAvatarCache = new Set<string>()
interface AvatarProps {
src?: string
name?: string
size?: number | string
shape?: 'circle' | 'square' | 'rounded'
className?: string
lazy?: boolean
onClick?: () => void
}
export const Avatar = React.memo(function Avatar({
src,
name,
size = 48,
shape = 'rounded',
className = '',
lazy = true,
onClick
}: AvatarProps) {
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
const [imageLoaded, setImageLoaded] = useState(isCached)
const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const getAvatarLetter = (): string => {
if (!name) return '?'
const chars = [...name]
return chars[0] || '?'
}
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
setIsInQueue(true)
avatarLoadQueue.enqueue(src).then(() => {
setShouldLoad(true)
}).catch(() => {
// 加载失败不要立刻显示错误,让浏览器渲染去报错
setShouldLoad(true)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{ rootMargin: '100px' }
)
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [src, lazy, shouldLoad, isInQueue, isCached])
// Reset state when src changes
useEffect(() => {
const cached = src ? loadedAvatarCache.has(src) : false
setImageLoaded(cached)
setImageError(false)
if (lazy && !cached) {
setShouldLoad(false)
setIsInQueue(false)
} else {
setShouldLoad(true)
}
}, [src, lazy])
// Check if image is already cached/loaded
useEffect(() => {
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true)
}
}, [src, shouldLoad])
const style = {
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}
const hasValidUrl = !!src && !imageError && shouldLoad
return (
<div
ref={containerRef}
className={`avatar-component ${shape} ${className}`}
style={style}
onClick={onClick}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={src}
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
if (src) loadedAvatarCache.add(src)
setImageLoaded(true)
}}
onError={() => setImageError(true)}
loading={lazy ? "lazy" : "eager"}
/>
</>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
</div>
)}
</div>
)
})

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
import './Sidebar.scss'
function Sidebar() {
@@ -34,6 +34,8 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
{/* 私聊分析 */}
<NavLink
to="/analytics"
@@ -84,10 +86,10 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
</nav>
<div className="sidebar-footer">
<NavLink
to="/settings"
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
@@ -96,8 +98,8 @@ function Sidebar() {
</span>
<span className="nav-label"></span>
</NavLink>
<button
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss'
import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar'
function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false)
@@ -28,7 +30,7 @@ function AnalyticsPage() {
try {
setLoadingStatus('正在统计消息数据...')
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data)
} else {
@@ -55,7 +57,12 @@ function AnalyticsPage() {
}
}
useEffect(() => { loadData() }, [])
const location = useLocation()
useEffect(() => {
const force = location.state?.forceRefresh === true
loadData(force)
}, [location.state])
const handleRefresh = () => loadData(true)
@@ -289,7 +296,7 @@ function AnalyticsPage() {
<div key={contact.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar">
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div>
<div className="contact-info">

View File

@@ -0,0 +1,119 @@
.analytics-welcome-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
background: var(--bg-primary);
color: var(--text-primary);
animation: fadeIn 0.4s ease-out;
.welcome-content {
text-align: center;
max-width: 600px;
.icon-wrapper {
width: 80px;
height: 80px;
margin: 0 auto 24px;
background: rgba(7, 193, 96, 0.1);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: #07c160;
svg {
width: 40px;
height: 40px;
}
}
h1 {
font-size: 28px;
margin-bottom: 12px;
font-weight: 600;
}
p {
color: var(--text-secondary);
margin-bottom: 40px;
font-size: 16px;
line-height: 1.6;
}
.action-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
button {
display: flex;
flex-direction: column;
align-items: center;
padding: 30px 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover:not(:disabled) {
transform: translateY(-2px);
border-color: #07c160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
.card-icon {
color: #07c160;
background: rgba(7, 193, 96, 0.1);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
filter: grayscale(100%);
}
.card-icon {
width: 50px;
height: 50px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
h3 {
font-size: 18px;
margin-bottom: 8px;
color: var(--text-primary);
}
span {
font-size: 13px;
color: var(--text-tertiary);
}
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom'
import { BarChart2, History, RefreshCcw } from 'lucide-react'
import { useAnalyticsStore } from '../stores/analyticsStore'
import './AnalyticsWelcomePage.scss'
function AnalyticsWelcomePage() {
const navigate = useNavigate()
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
// 如果用户点击“加载缓存”但缓存为空AnalyticsPage 的逻辑loadData 不带 force将尝试从后端缓存加载。
// 如果后端缓存也为空,则会重新计算。
// 我们也可以检查 `lastLoadTime` 来显示“上次更新xxx”如果已持久化
const { lastLoadTime } = useAnalyticsStore()
const handleLoadCache = () => {
navigate('/analytics/view')
}
const handleNewAnalysis = () => {
navigate('/analytics/view', { state: { forceRefresh: true } })
}
const formatLastTime = (ts: number | null) => {
if (!ts) return '无记录'
return new Date(ts).toLocaleString()
}
return (
<div className="analytics-welcome-container">
<div className="welcome-content">
<div className="icon-wrapper">
<BarChart2 size={40} />
</div>
<h1></h1>
<p>
WeFlow <br />
</p>
<div className="action-cards">
<button onClick={handleLoadCache}>
<div className="card-icon">
<History size={24} />
</div>
<h3></h3>
<span><br />(: {formatLastTime(lastLoadTime)})</span>
</button>
<button onClick={handleNewAnalysis}>
<div className="card-icon">
<RefreshCcw size={24} />
</div>
<h3></h3>
<span><br />()</span>
</button>
</div>
</div>
</div>
)
}
export default AnalyticsWelcomePage

View File

@@ -1426,10 +1426,12 @@
height: 6px;
opacity: 0.5;
}
50% {
height: 16px;
opacity: 1;
}
100% {
height: 6px;
opacity: 0.5;
@@ -1875,4 +1877,4 @@
opacity: 1;
transform: translateX(0);
}
}
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis'
@@ -23,65 +24,10 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[]
}
// 全局头像加载队列管理器(限制并发,避免卡顿)
class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = []
private loading = new Set<string>()
private readonly maxConcurrent = 1 // 一次只加载1个头像避免卡顿
private readonly delayBetweenBatches = 100 // 批次间延迟100ms给UI喘息时间
async enqueue(url: string): Promise<void> {
// 如果已经在加载中,直接返回
if (this.loading.has(url)) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
this.queue.push({ url, resolve, reject })
this.processQueue()
})
}
private async processQueue() {
// 如果已达到最大并发数,等待
if (this.loading.size >= this.maxConcurrent) {
return
}
// 如果队列为空,返回
if (this.queue.length === 0) {
return
}
// 取出一个任务
const task = this.queue.shift()
if (!task) return
this.loading.add(task.url)
// 加载图片
const img = new Image()
img.onload = () => {
this.loading.delete(task.url)
task.resolve()
// 延迟一下再处理下一个,避免一次性加载太多
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.onerror = () => {
this.loading.delete(task.url)
task.reject()
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.src = task.url
}
clear() {
this.queue = []
this.loading.clear()
}
}
const avatarLoadQueue = new AvatarLoadQueue()
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import { Avatar } from '../components/Avatar'
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
@@ -97,7 +43,7 @@ const SessionItem = React.memo(function SessionItem({
formatTime: (timestamp: number) => string
}) {
// 缓存格式化的时间
const timeText = useMemo(() =>
const timeText = useMemo(() =>
formatTime(session.lastTimestamp || session.sortTimestamp),
[formatTime, session.lastTimestamp, session.sortTimestamp]
)
@@ -107,7 +53,12 @@ const SessionItem = React.memo(function SessionItem({
className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)}
>
<SessionAvatar session={session} size={48} />
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={48}
className={session.username.includes('@chatroom') ? 'group' : ''}
/>
<div className="session-info">
<div className="session-top">
<span className="session-name">{session.displayName || session.username}</span>
@@ -138,109 +89,7 @@ const SessionItem = React.memo(function SessionItem({
)
})
const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
const [imageLoaded, setImageLoaded] = useState(false)
const [imageError, setImageError] = useState(false)
const [shouldLoad, setShouldLoad] = useState(false)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const isGroup = session.username.includes('@chatroom')
const getAvatarLetter = (): string => {
const name = session.displayName || session.username
if (!name) return '?'
const chars = [...name]
return chars[0] || '?'
}
// 使用 Intersection Observer 实现懒加载(优化性能)
useEffect(() => {
if (!containerRef.current || shouldLoad || isInQueue) return
if (!session.avatarUrl) {
// 没有头像URL不需要加载
return
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
// 加入加载队列,而不是立即加载
setIsInQueue(true)
avatarLoadQueue.enqueue(session.avatarUrl!).then(() => {
setShouldLoad(true)
}).catch(() => {
setImageError(true)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{
rootMargin: '50px' // 减少预加载距离只提前50px
}
)
observer.observe(containerRef.current)
return () => {
observer.disconnect()
}
}, [session.avatarUrl, shouldLoad, isInQueue])
// 当 avatarUrl 变化时重置状态
useEffect(() => {
setImageLoaded(false)
setImageError(false)
setShouldLoad(false)
setIsInQueue(false)
}, [session.avatarUrl])
// 检查图片是否已经从缓存加载完成
useEffect(() => {
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true)
}
}, [session.avatarUrl, shouldLoad])
const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
return (
<div
ref={containerRef}
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
style={{ width: size, height: size }}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={session.avatarUrl}
alt=""
className={imageLoaded ? 'loaded' : ''}
onLoad={() => setImageLoaded(true)}
onError={() => setImageError(true)}
loading="lazy"
/>
</>
) : (
<span className="avatar-letter">{getAvatarLetter()}</span>
)}
</div>
)
}, (prevProps, nextProps) => {
// 自定义比较函数,只在关键属性变化时重渲染
return (
prevProps.session.username === nextProps.session.username &&
prevProps.session.displayName === nextProps.session.displayName &&
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
prevProps.size === nextProps.size
)
})
function ChatPage(_props: ChatPageProps) {
const {
@@ -278,6 +127,7 @@ function ChatPage(_props: ChatPageProps) {
const sessionListRef = useRef<HTMLDivElement>(null)
const [currentOffset, setCurrentOffset] = useState(0)
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
const [sidebarWidth, setSidebarWidth] = useState(260)
const [isResizing, setIsResizing] = useState(false)
@@ -287,7 +137,7 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false)
// 联系人信息加载控制
const isEnrichingRef = useRef(false)
const enrichCancelledRef = useRef(false)
@@ -354,6 +204,9 @@ function ChatPage(_props: ChatPageProps) {
setConnected(true)
await loadSessions()
await loadMyAvatar()
// 获取 myWxid 用于匹配个人头像
const wxid = await window.electronAPI.config.get('myWxid')
if (wxid) setMyWxid(wxid as string)
} else {
setConnectionError(result.error || '连接失败')
}
@@ -380,10 +233,8 @@ function ChatPage(_props: ChatPageProps) {
// 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) {
setSessions(nextSessions)
// 延迟启动联系人信息加载,确保UI先渲染完成
setTimeout(() => {
void enrichSessionsContactInfo(nextSessions)
}, 500)
// 立即启动联系人信息加载,不再延迟 500ms
void enrichSessionsContactInfo(nextSessions)
} else {
console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray)
@@ -407,31 +258,30 @@ function ChatPage(_props: ChatPageProps) {
// 分批异步加载联系人信息(优化性能:防止重复加载,滚动时暂停,只在空闲时加载)
const enrichSessionsContactInfo = async (sessions: ChatSession[]) => {
if (sessions.length === 0) return
// 防止重复加载
if (isEnrichingRef.current) {
console.log('[性能监控] 联系人信息正在加载中,跳过重复请求')
return
}
isEnrichingRef.current = true
enrichCancelledRef.current = false
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now()
// 延迟启动,等待UI渲染完成
await new Promise(resolve => setTimeout(resolve, 500))
// 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
// 检查是否被取消
if (enrichCancelledRef.current) {
isEnrichingRef.current = false
return
}
try {
// 找出需要加载联系人信息的会话(没有缓存的)
const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username))
// 找出需要加载联系人信息的会话(没有头像或者没有显示名称的)
const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username)
if (needEnrich.length === 0) {
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
isEnrichingRef.current = false
@@ -443,7 +293,7 @@ function ChatPage(_props: ChatPageProps) {
// 进一步减少批次大小每批3个避免DLL调用阻塞
const batchSize = 3
let loadedCount = 0
for (let i = 0; i < needEnrich.length; i += batchSize) {
// 如果正在滚动,暂停加载
if (isScrollingRef.current) {
@@ -454,14 +304,14 @@ function ChatPage(_props: ChatPageProps) {
}
if (enrichCancelledRef.current) break
}
// 检查是否被取消
if (enrichCancelledRef.current) break
const batchStart = performance.now()
const batch = needEnrich.slice(i, i + batchSize)
const usernames = batch.map(s => s.username)
// 使用 requestIdleCallback 延迟执行避免阻塞UI
await new Promise<void>((resolve) => {
if ('requestIdleCallback' in window) {
@@ -474,13 +324,13 @@ function ChatPage(_props: ChatPageProps) {
}, 300)
}
})
loadedCount += batch.length
const batchTime = performance.now() - batchStart
if (batchTime > 200) {
console.warn(`[性能监控] 批次 ${Math.floor(i / batchSize) + 1}/${Math.ceil(needEnrich.length / batchSize)} 耗时: ${batchTime.toFixed(2)}ms (已加载: ${loadedCount}/${needEnrich.length})`)
}
// 批次间延迟给UI更多时间DLL调用可能阻塞需要更长的延迟
if (i + batchSize < needEnrich.length && !enrichCancelledRef.current) {
// 如果不在滚动,可以延迟短一点
@@ -488,7 +338,7 @@ function ChatPage(_props: ChatPageProps) {
await new Promise(resolve => setTimeout(resolve, delay))
}
}
const totalTime = performance.now() - totalStart
if (!enrichCancelledRef.current) {
console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`)
@@ -570,23 +420,35 @@ function ChatPage(_props: ChatPageProps) {
try {
// 在 DLL 调用前让出控制权(使用 setTimeout 0 代替 setImmediate
await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权
await new Promise(resolve => setTimeout(resolve, 0))
const totalTime = performance.now() - startTime
if (dllTime > 50 || totalTime > 100) {
console.warn(`[性能监控] DLL调用耗时: ${dllTime.toFixed(2)}ms, 总耗时: ${totalTime.toFixed(2)}ms, usernames: ${usernames.length}`)
}
if (result.success && result.contacts) {
// 将更新加入队列,而不是立即更新
// 将更新加入队列,用于侧边栏更新
for (const [username, contact] of Object.entries(result.contacts)) {
contactUpdateQueueRef.current.set(username, contact)
// 如果是自己的信息且当前个人头像为空,同步更新
if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) {
console.log('[ChatPage] 从联系人同步获取到个人头像')
setMyAvatarUrl(contact.avatarUrl)
}
// 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用
senderAvatarCache.set(username, {
avatarUrl: contact.avatarUrl,
displayName: contact.displayName
})
}
// 触发批量更新
flushContactUpdates()
@@ -644,7 +506,7 @@ function ChatPage(_props: ChatPageProps) {
const session = sessionMapRef.current.get(sessionId)
const unreadCount = session?.unreadCount ?? 0
const messageLimit = offset === 0 && unreadCount > 99 ? 30 : 50
if (offset === 0) {
setLoadingMessages(true)
setMessages([])
@@ -660,6 +522,31 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) {
if (offset === 0) {
setMessages(result.messages)
// 预取发送者信息:在关闭加载遮罩前处理
const unreadCount = session?.unreadCount ?? 0
const isGroup = sessionId.includes('@chatroom')
if (isGroup && result.messages.length > 0) {
const unknownSenders = [...new Set(result.messages
.filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername))
.map(m => m.senderUsername as string)
)]
if (unknownSenders.length > 0) {
console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length}`)
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => {
if (!senderAvatarLoading.has(username)) {
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
}
})
// 确保在请求完成后清理 loading 状态
batchPromise.finally(() => {
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
})
}
}
// 首次加载滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
@@ -668,6 +555,27 @@ function ChatPage(_props: ChatPageProps) {
})
} else {
appendMessages(result.messages, true)
// 加载更多也同样处理发送者信息预取
const isGroup = sessionId.includes('@chatroom')
if (isGroup) {
const unknownSenders = [...new Set(result.messages
.filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername))
.map(m => m.senderUsername as string)
)]
if (unknownSenders.length > 0) {
const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => {
if (!senderAvatarLoading.has(username)) {
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
}
})
batchPromise.finally(() => {
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
})
}
}
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
if (firstMsgEl && listEl) {
requestAnimationFrame(() => {
@@ -742,7 +650,7 @@ function ChatPage(_props: ChatPageProps) {
scrollTimeoutRef.current = requestAnimationFrame(() => {
if (!messageListRef.current) return
const { scrollTop, clientHeight, scrollHeight } = messageListRef.current
// 显示回到底部按钮:距离底部超过 300px
@@ -842,7 +750,7 @@ function ChatPage(_props: ChatPageProps) {
if (!isConnected && !isConnecting) {
connect()
}
// 组件卸载时清理
return () => {
avatarLoadQueue.clear()
@@ -906,7 +814,7 @@ function ChatPage(_props: ChatPageProps) {
})
}
if (payloads.length > 0) {
window.electronAPI.image.preload(payloads).catch(() => {})
window.electronAPI.image.preload(payloads).catch(() => { })
}
}, [currentSessionId, messages])
@@ -1101,8 +1009,10 @@ function ChatPage(_props: ChatPageProps) {
</div>
)}
{/* ... (previous content) ... */}
{isLoadingSessions ? (
<div className="loading-sessions">
{/* ... (skeleton items) ... */}
{[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar" />
@@ -1114,16 +1024,14 @@ function ChatPage(_props: ChatPageProps) {
))}
</div>
) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? (
<div
<div
className="session-list"
ref={sessionListRef}
onScroll={() => {
// 标记正在滚动,暂停联系人信息加载
isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current)
}
// 滚动结束后200ms才认为滚动停止
sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false
sessionScrollTimeoutRef.current = null
@@ -1147,6 +1055,8 @@ function ChatPage(_props: ChatPageProps) {
<p className="hint"></p>
</div>
)}
</div>
{/* 拖动调节条 */}
@@ -1157,7 +1067,12 @@ function ChatPage(_props: ChatPageProps) {
{currentSession ? (
<>
<div className="message-header">
<SessionAvatar session={currentSession} size={40} />
<Avatar
src={currentSession.avatarUrl}
name={currentSession.displayName || currentSession.username}
size={40}
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
/>
<div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChat(currentSession.username) && (
@@ -1195,56 +1110,56 @@ function ChatPage(_props: ChatPageProps) {
ref={messageListRef}
onScroll={handleScroll}
>
{hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
)}
{messages.map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
// 显示时间第一条消息或者与上一条消息间隔超过5分钟
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
const isSent = msg.isSend === 1
const isSystem = msg.localType === 10000
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span></span>
{hasMoreMessages && (
<div className={`load-more-trigger ${isLoadingMore ? 'loading' : ''}`}>
{isLoadingMore ? (
<>
<Loader2 size={14} />
<span>...</span>
</>
) : (
<span></span>
)}
</div>
)}
{messages.map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg)
// 显示时间第一条消息或者与上一条消息间隔超过5分钟
const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300)
const isSent = msg.isSend === 1
const isSystem = msg.localType === 10000
// 系统消息居中显示
const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received')
const messageKey = getMessageKey(msg)
return (
<div key={messageKey} className={`message-wrapper ${wrapperClass} ${highlightedMessageSet.has(messageKey) ? 'new-message' : ''}`}>
{showDateDivider && (
<div className="date-divider">
<span>{formatDateDivider(msg.createTime)}</span>
</div>
)}
<MessageBubble
message={msg}
session={currentSession}
showTime={!showDateDivider && showTime}
myAvatarUrl={myAvatarUrl}
isGroupChat={isGroupChat(currentSession.username)}
/>
</div>
)
})}
{/* 回到底部按钮 */}
<div className={`scroll-to-bottom ${showScrollToBottom ? 'show' : ''}`} onClick={scrollToBottom}>
<ChevronDown size={16} />
<span></span>
</div>
</div>
{/* 会话详情面板 */}
{showDetailPanel && (
@@ -1434,7 +1349,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
return 'image/webp'
}
} catch {}
} catch { }
return 'image/jpeg'
}, [])
@@ -1473,9 +1388,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
})
}
// 群聊中获取发送者信息
// 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取)
useEffect(() => {
if (isGroupChat && !isSent && message.senderUsername) {
if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) {
const sender = message.senderUsername
const cached = senderAvatarCache.get(sender)
if (cached) {
@@ -1501,11 +1416,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName)
}
}).catch(() => {}).finally(() => {
}).catch(() => { }).finally(() => {
senderAvatarLoading.delete(sender)
})
}
}, [isGroupChat, isSent, message.senderUsername])
}, [isGroupChat, isSent, message.senderUsername, myAvatarUrl])
// 自动下载表情包
useEffect(() => {
@@ -1597,7 +1512,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
}
setImageHasUpdate(Boolean(result.hasUpdate))
}
}).catch(() => {})
}).catch(() => { })
return () => {
cancelled = true
}
@@ -1672,11 +1587,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
const bubbleClass = isSent ? 'sent' : 'received'
// 头像逻辑:
// - 自己发的:使用 myAvatarUrl
// - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救)
// - 群聊中对方发的:使用发送者头像
// - 私聊中对方发的:使用会话头像
const avatarUrl = isSent
? myAvatarUrl
? (myAvatarUrl || senderAvatarUrl)
: (isGroupChat ? senderAvatarUrl : session.avatarUrl)
const avatarLetter = isSent
? '我'
@@ -1685,6 +1600,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
// 是否有引用消息
const hasQuote = message.quotedContent && message.quotedContent.length > 0
// 去除企业微信 ID 前缀
const cleanMessageContent = (content: string) => {
if (!content) return ''
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
}
// 解析混合文本和表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
@@ -1761,13 +1682,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
</button>
)}
</div>
{showImagePreview && (
{showImagePreview && createPortal(
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
<X size={16} />
</button>
</div>
</div>,
document.body
)}
</>
)
@@ -1895,14 +1817,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
<div className="bubble-content">
<div className="quoted-message">
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(message.quotedContent || '')}</span>
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(message.parsedContent)}</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
</div>
)
}
// 普通消息
return <div className="bubble-content">{renderTextWithEmoji(message.parsedContent)}</div>
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
}
return (
@@ -1914,11 +1836,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
)}
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
<div className="bubble-avatar">
{avatarUrl ? (
<img src={avatarUrl} alt="" />
) : (
<span className="avatar-letter">{avatarLetter}</span>
)}
<Avatar
src={avatarUrl}
name={!isSent ? (isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : '我'}
size={36}
className="bubble-avatar"
// If it's sent by me (isSent), we might not want 'group' class even if it's a group chat.
// But 'group' class mainly handles default avatar icon.
// Let's rely on standard Avatar behavior.
/>
</div>
<div className="bubble-body">
{/* 群聊中显示发送者名称 */}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker'
import './GroupAnalyticsPage.scss'
@@ -177,7 +178,7 @@ function GroupAnalyticsPage() {
const getMediaOption = () => {
if (!mediaStats || mediaStats.typeCounts.length === 0) return {}
// 定义颜色映射
const colorMap: Record<number, string> = {
1: '#3b82f6', // 文本 - 蓝色
@@ -188,13 +189,13 @@ function GroupAnalyticsPage() {
49: '#14b8a6', // 链接/文件 - 青色
[-1]: '#6b7280', // 其他 - 灰色
}
const data = mediaStats.typeCounts.map(item => ({
name: item.name,
value: item.count,
itemStyle: { color: colorMap[item.type] || '#6b7280' }
}))
return {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
@@ -202,8 +203,8 @@ function GroupAnalyticsPage() {
radius: ['40%', '70%'],
center: ['50%', '50%'],
itemStyle: { borderRadius: 8, borderColor: 'rgba(255,255,255,0.1)', borderWidth: 2 },
label: {
show: true,
label: {
show: true,
formatter: (params: { name: string; percent: number }) => {
// 只显示占比大于3%的标签
return params.percent > 3 ? `${params.name}\n${params.percent.toFixed(1)}%` : ''
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
</button>
<div className="modal-content">
<div className="member-avatar large">
{selectedMember.avatarUrl ? (
<img src={selectedMember.avatarUrl} alt="" />
) : (
<div className="avatar-placeholder"><User size={48} /></div>
)}
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
</div>
<h3 className="member-display-name">{selectedMember.displayName}</h3>
<div className="member-details">
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
onClick={() => handleGroupSelect(group)}
>
<div className="group-avatar">
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
<Avatar src={group.avatarUrl} name={group.displayName} size={44} />
</div>
<div className="group-info">
<span className="group-name">{group.displayName}</span>
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
<div className="function-menu">
<div className="selected-group-info">
<div className="group-avatar large">
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
</div>
<h2>{selectedGroup?.displayName}</h2>
<p>{selectedGroup?.memberCount} </p>
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
{members.map(member => (
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
<div className="member-avatar">
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={member.avatarUrl} name={member.displayName} size={48} />
</div>
<span className="member-name">{member.displayName}</span>
</div>
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
<div key={item.member.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar">
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
<Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div>
<div className="contact-info">

View File

@@ -484,6 +484,7 @@ function SettingsPage() {
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
</button>
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
{isFetchingImageKey && <div className="form-hint status-text">...</div>}
</div>
<div className="form-group">

View File

@@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
<div className="field-hint"></div>
<div className="field-hint"></div>
</div>
)}
@@ -532,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
</button>
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
<div className="field-hint"></div>
{isFetchingImageKey && <div className="field-hint status-text">...</div>}
</div>
)}

View File

@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
AGREEMENT_ACCEPTED: 'agreementAccepted',
LOG_ENABLED: 'logEnabled',
ONBOARDING_DONE: 'onboardingDone',
LLM_MODEL_PATH: 'llmModelPath',
IMAGE_XOR_KEY: 'imageXorKey',
IMAGE_AES_KEY: 'imageAesKey'
} as const
@@ -132,6 +133,17 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
}
// 获取 LLM 模型路径
export async function getLlmModelPath(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
return (value as string) || null
}
// 设置 LLM 模型路径
export async function setLlmModelPath(path: string): Promise<void> {
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
}
// 清除所有配置
export async function clearConfig(): Promise<void> {
await config.clear()

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ChatStatistics {
totalMessages: number
@@ -36,11 +37,11 @@ interface AnalyticsState {
statistics: ChatStatistics | null
rankings: ContactRanking[]
timeDistribution: TimeDistribution | null
// 状态
isLoaded: boolean
lastLoadTime: number | null
// Actions
setStatistics: (data: ChatStatistics) => void
setRankings: (data: ContactRanking[]) => void
@@ -49,22 +50,29 @@ interface AnalyticsState {
clearCache: () => void
}
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null,
export const useAnalyticsStore = create<AnalyticsState>()(
persist(
(set) => ({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null,
setStatistics: (data) => set({ statistics: data }),
setRankings: (data) => set({ rankings: data }),
setTimeDistribution: (data) => set({ timeDistribution: data }),
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
clearCache: () => set({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null
}),
}))
setStatistics: (data) => set({ statistics: data }),
setRankings: (data) => set({ rankings: data }),
setTimeDistribution: (data) => set({ timeDistribution: data }),
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
clearCache: () => set({
statistics: null,
rankings: [],
timeDistribution: null,
isLoaded: false,
lastLoadTime: null
}),
}),
{
name: 'analytics-storage',
}
)
)

View File

@@ -45,6 +45,7 @@ export interface ElectronAPI {
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean>
}
key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
@@ -95,6 +96,7 @@ export interface ElectronAPI {
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
}
image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
@@ -103,7 +105,7 @@ export interface ElectronAPI {
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
}
analytics: {
getOverallStatistics: () => Promise<{
getOverallStatistics: (force?: boolean) => Promise<{
success: boolean
data?: {
totalMessages: number
@@ -262,12 +264,12 @@ export interface ElectronAPI {
fastestFriend: string
fastestTime: number
} | null
topPhrases: Array<{
phrase: string
count: number
}>
}
error?: string
topPhrases: Array<{
phrase: string
count: number
}>
}
error?: string
}>
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{
success: boolean

View File

@@ -0,0 +1,74 @@
// 全局头像加载队列管理器(限制并发,避免卡顿)
export class AvatarLoadQueue {
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
private loading = new Map<string, Promise<void>>()
private activeCount = 0
private readonly maxConcurrent = 3
private readonly delayBetweenBatches = 10
private static instance: AvatarLoadQueue
public static getInstance(): AvatarLoadQueue {
if (!AvatarLoadQueue.instance) {
AvatarLoadQueue.instance = new AvatarLoadQueue()
}
return AvatarLoadQueue.instance
}
async enqueue(url: string): Promise<void> {
if (!url) return Promise.resolve()
// 核心修复:防止重复并发请求同一个 URL
const existingPromise = this.loading.get(url)
if (existingPromise) {
return existingPromise
}
const loadPromise = new Promise<void>((resolve, reject) => {
this.queue.push({ url, resolve, reject })
this.processQueue()
})
this.loading.set(url, loadPromise)
loadPromise.finally(() => {
this.loading.delete(url)
})
return loadPromise
}
private async processQueue() {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
return
}
const task = this.queue.shift()
if (!task) return
this.activeCount++
const img = new Image()
img.onload = () => {
this.activeCount--
task.resolve()
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.onerror = () => {
this.activeCount--
task.reject(new Error(`Failed: ${task.url}`))
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
}
img.src = task.url
this.processQueue()
}
clear() {
this.queue = []
this.loading.clear()
this.activeCount = 0
}
}
export const avatarLoadQueue = AvatarLoadQueue.getInstance()

View File

@@ -10,6 +10,14 @@ export default defineConfig({
port: 3000,
strictPort: false // 如果3000被占用自动尝试下一个
},
build: {
commonjsOptions: {
ignoreDynamicRequires: true
}
},
optimizeDeps: {
exclude: []
},
plugins: [
react(),
electron([
@@ -19,7 +27,11 @@ export default defineConfig({
build: {
outDir: 'dist-electron',
rollupOptions: {
external: ['better-sqlite3', 'koffi']
external: [
'better-sqlite3',
'koffi',
'fsevents'
]
}
}
}
@@ -30,7 +42,10 @@ export default defineConfig({
build: {
outDir: 'dist-electron',
rollupOptions: {
external: ['koffi'],
external: [
'koffi',
'fsevents'
],
output: {
entryFileNames: 'annualReportWorker.js',
inlineDynamicImports: true
@@ -53,6 +68,25 @@ export default defineConfig({
}
}
},
{
entry: 'electron/wcdbWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'better-sqlite3',
'koffi',
'fsevents'
],
output: {
entryFileNames: 'wcdbWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{
entry: 'electron/preload.ts',
onstart(options) {