feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度

fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
This commit is contained in:
cc
2026-01-14 22:43:42 +08:00
parent 3151f79ee7
commit 2e41a03c96
32 changed files with 2772 additions and 4130 deletions

1
echotrace Submodule

Submodule echotrace added at 98280f0d0d

View File

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

View File

@@ -382,6 +382,8 @@ function registerIpcHandlers() {
return true return true
}) })
// 聊天相关 // 聊天相关
ipcMain.handle('chat:connect', async () => { ipcMain.handle('chat:connect', async () => {
return chatService.connect() return chatService.connect()
@@ -468,8 +470,8 @@ function registerIpcHandlers() {
}) })
// 数据分析相关 // 数据分析相关
ipcMain.handle('analytics:getOverallStatistics', async () => { ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
return analyticsService.getOverallStatistics() return analyticsService.getOverallStatistics(force)
}) })
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => { ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {

View File

@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) => open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close') close: () => ipcRenderer.invoke('wcdb:close'),
}, },
// 密钥获取 // 密钥获取

View File

@@ -1,5 +1,8 @@
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { join } from 'path'
import { readFile, writeFile } from 'fs/promises'
import { app } from 'electron'
export interface ChatStatistics { export interface ChatStatistics {
totalMessages: number totalMessages: number
@@ -253,15 +256,31 @@ class AnalyticsService {
sessionIds: string[], sessionIds: string[],
beginTimestamp = 0, beginTimestamp = 0,
endTimestamp = 0, endTimestamp = 0,
window?: any window?: any,
force = false
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> { ): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp) 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) { if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
return { success: true, data: this.aggregateCache.data, source: 'cache' } 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) { if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
return this.aggregatePromise.promise return this.aggregatePromise.promise
} }
@@ -291,7 +310,12 @@ class AnalyticsService {
this.aggregatePromise = { key: cacheKey, promise } this.aggregatePromise = { key: cacheKey, promise }
try { 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 { } finally {
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) { if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
this.aggregatePromise = null 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( private normalizeAggregateSessions(
sessions: Record<string, any> | undefined, sessions: Record<string, any> | undefined,
idMap: Record<string, string> | undefined idMap: Record<string, string> | undefined
@@ -326,7 +369,7 @@ class AnalyticsService {
void results void results
} }
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> { async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error } if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
@@ -340,7 +383,7 @@ class AnalyticsService {
const win = BrowserWindow.getAllWindows()[0] const win = BrowserWindow.getAllWindows()[0]
this.setProgress(win, '正在执行原生数据聚合...', 30) 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) { if (!result.success || !result.data) {
return { success: false, error: result.error || '聚合统计失败' } return { success: false, error: result.error || '聚合统计失败' }
@@ -458,8 +501,8 @@ class AnalyticsService {
const d = result.data const d = result.data
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat // SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
// 前端期望 1=Mon...7=Sun // 前端期望 1=周一...7=周日
const weekdayDistribution: Record<number, number> = {} const weekdayDistribution: Record<number, number> = {}
for (const [w, count] of Object.entries(d.weekday)) { for (const [w, count] of Object.entries(d.weekday)) {
const sqliteW = parseInt(w, 10) const sqliteW = parseInt(w, 10)

View File

@@ -667,8 +667,7 @@ class ChatService {
const messages: Message[] = [] const messages: Message[] = []
for (const row of rows) { for (const row of rows) {
const content = this.decodeMessageContent( const rawMessageContent = this.getRowField(row, [
this.getRowField(row, [
'message_content', 'message_content',
'messageContent', 'messageContent',
'content', 'content',
@@ -676,15 +675,16 @@ class ChatService {
'msgContent', 'msgContent',
'WCDB_CT_message_content', 'WCDB_CT_message_content',
'WCDB_CT_messageContent' 'WCDB_CT_messageContent'
]), ]);
this.getRowField(row, [ const rawCompressContent = this.getRowField(row, [
'compress_content', 'compress_content',
'compressContent', 'compressContent',
'compressed_content', 'compressed_content',
'WCDB_CT_compress_content', 'WCDB_CT_compress_content',
'WCDB_CT_compressContent' '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 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']) const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10) let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
@@ -696,6 +696,16 @@ class ChatService {
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0 const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0
if (isSend === null) { if (isSend === null) {
isSend = expectedIsSend 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}`)
} }
} }
@@ -1481,9 +1491,9 @@ class ChatService {
*/ */
private decodeMessageContent(messageContent: any, compressContent: any): string { private decodeMessageContent(messageContent: any, compressContent: any): string {
// 优先使用 compress_content // 优先使用 compress_content
let content = this.decodeMaybeCompressed(compressContent) let content = this.decodeMaybeCompressed(compressContent, 'compress_content')
if (!content || content.length === 0) { if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent) content = this.decodeMaybeCompressed(messageContent, 'message_content')
} }
return content return content
} }
@@ -1491,12 +1501,14 @@ class ChatService {
/** /**
* 尝试解码可能压缩的内容 * 尝试解码可能压缩的内容
*/ */
private decodeMaybeCompressed(raw: any): string { private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string {
if (!raw) return '' if (!raw) return ''
// console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw)
// 如果是 Buffer/Uint8Array // 如果是 Buffer/Uint8Array
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) { if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return this.decodeBinaryContent(Buffer.from(raw)) return this.decodeBinaryContent(Buffer.from(raw), String(raw))
} }
// 如果是字符串 // 如果是字符串
@@ -1507,7 +1519,9 @@ class ChatService {
if (this.looksLikeHex(raw)) { if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex') const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) { if (bytes.length > 0) {
return this.decodeBinaryContent(bytes) const result = this.decodeBinaryContent(bytes, raw)
// console.log(`[ChatService] HEX decoded result: ${result}`)
return result
} }
} }
@@ -1515,7 +1529,7 @@ class ChatService {
if (this.looksLikeBase64(raw)) { if (this.looksLikeBase64(raw)) {
try { try {
const bytes = Buffer.from(raw, 'base64') const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes) return this.decodeBinaryContent(bytes, raw)
} catch { } } catch { }
} }
@@ -1529,7 +1543,7 @@ class ChatService {
/** /**
* 解码二进制内容(处理 zstd 压缩) * 解码二进制内容(处理 zstd 压缩)
*/ */
private decodeBinaryContent(data: Buffer): string { private decodeBinaryContent(data: Buffer, fallbackValue?: string): string {
if (data.length === 0) return '' if (data.length === 0) return ''
try { try {
@@ -1556,10 +1570,16 @@ class ChatService {
return decoded.replace(/\uFFFD/g, '') return decoded.replace(/\uFFFD/g, '')
} }
// 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue
if (fallbackValue && replacementCount > 0) {
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`)
return fallbackValue
}
// 尝试 latin1 解码 // 尝试 latin1 解码
return data.toString('latin1') return data.toString('latin1')
} catch { } catch {
return '' return fallbackValue || ''
} }
} }

View File

@@ -459,7 +459,7 @@ class ExportService {
const [displayNames, avatarUrls] = await Promise.all([ const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(Array.from(lookupUsernames)), wcdbService.getDisplayNames(Array.from(lookupUsernames)),
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} }) includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
]) ])
for (const member of rawMembers) { for (const member of rawMembers) {
@@ -590,7 +590,11 @@ class ExportService {
} }
} }
if (!data) continue 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') const base64 = data.toString('base64')
result.set(member.username, `data:${finalMime};base64,${base64}`) result.set(member.username, `data:${finalMime};base64,${base64}`)
} catch { } catch {
@@ -601,6 +605,39 @@ class ExportService {
return result 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 { private inferImageMime(ext: string): string {
switch (ext.toLowerCase()) { switch (ext.toLowerCase()) {
case '.png': case '.png':
@@ -659,7 +696,8 @@ class ExportService {
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => { const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || { const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
platformId: msg.senderUsername, platformId: msg.senderUsername,
accountName: msg.senderUsername accountName: msg.senderUsername,
groupNickname: undefined
} }
return { return {
sender: msg.senderUsername, sender: msg.senderUsername,
@@ -811,7 +849,8 @@ class ExportService {
displayName: sessionInfo.displayName, displayName: sessionInfo.displayName,
type: isGroup ? '群聊' : '私聊', type: isGroup ? '群聊' : '私聊',
lastTimestamp: collected.lastTime, lastTimestamp: collected.lastTime,
messageCount: allMessages.length messageCount: allMessages.length,
avatar: undefined as string | undefined
}, },
messages: allMessages 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 }> { async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()

View File

@@ -1,7 +1,7 @@
import { app, BrowserWindow } from 'electron' import { app, BrowserWindow } from 'electron'
import { basename, dirname, extname, join } from 'path' import { basename, dirname, extname, join } from 'path'
import { pathToFileURL } from 'url' 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 { writeFile } from 'fs/promises'
import crypto from 'crypto' import crypto from 'crypto'
import { Worker } from 'worker_threads' import { Worker } from 'worker_threads'
@@ -32,11 +32,45 @@ export class ImageDecryptService {
private logInfo(message: string, meta?: Record<string, unknown>): void { private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return 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) { if (meta) {
console.info(message, meta) console.info(message, meta)
} else { } else {
console.info(message) 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 }> { 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) { for (const key of cacheKeys) {
const cached = this.resolvedCache.get(key) const cached = this.resolvedCache.get(key)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isImageFile(cached)) {
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
const dataUrl = this.fileToDataUrl(cached) const dataUrl = this.fileToDataUrl(cached)
const isThumb = this.isThumbnailPath(cached) const isThumb = this.isThumbnailPath(cached)
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
@@ -68,6 +103,7 @@ export class ImageDecryptService {
for (const key of cacheKeys) { for (const key of cacheKeys) {
const existing = this.findCachedOutput(key, false, payload.sessionId) const existing = this.findCachedOutput(key, false, payload.sessionId)
if (existing) { if (existing) {
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing) this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
const dataUrl = this.fileToDataUrl(existing) const dataUrl = this.fileToDataUrl(existing)
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
@@ -81,6 +117,7 @@ export class ImageDecryptService {
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
} }
} }
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到缓存图片' } return { success: false, error: '未找到缓存图片' }
} }
@@ -120,15 +157,18 @@ export class ImageDecryptService {
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
cacheKey: string cacheKey: string
): Promise<DecryptResult> { ): Promise<DecryptResult> {
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
try { try {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
if (!wxid || !dbPath) { if (!wxid || !dbPath) {
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
return { success: false, error: '未配置账号或数据库路径' } return { success: false, error: '未配置账号或数据库路径' }
} }
const accountDir = this.resolveAccountDir(dbPath, wxid) const accountDir = this.resolveAccountDir(dbPath, wxid)
if (!accountDir) { if (!accountDir) {
this.logError('未找到账号目录', undefined, { dbPath, wxid })
return { success: false, error: '未找到账号目录' } return { success: false, error: '未找到账号目录' }
} }
@@ -142,12 +182,16 @@ export class ImageDecryptService {
// 如果要求高清图但没找到,直接返回提示 // 如果要求高清图但没找到,直接返回提示
if (!datPath && payload.force) { if (!datPath && payload.force) {
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' } return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
} }
if (!datPath) { if (!datPath) {
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: '未找到图片文件' } return { success: false, error: '未找到图片文件' }
} }
this.logInfo('找到DAT文件', { datPath })
if (!extname(datPath).toLowerCase().includes('dat')) { if (!extname(datPath).toLowerCase().includes('dat')) {
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
const dataUrl = this.fileToDataUrl(datPath) const dataUrl = this.fileToDataUrl(datPath)
@@ -160,6 +204,7 @@ export class ImageDecryptService {
// 查找已缓存的解密文件 // 查找已缓存的解密文件
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId) const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
if (existing) { if (existing) {
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
const isHd = this.isHdPath(existing) const isHd = this.isHdPath(existing)
// 如果要求高清但找到的是缩略图,继续解密高清图 // 如果要求高清但找到的是缩略图,继续解密高清图
if (!(payload.force && !isHd)) { if (!(payload.force && !isHd)) {
@@ -192,12 +237,14 @@ export class ImageDecryptService {
const aesKeyRaw = this.configService.get('imageAesKey') const aesKeyRaw = this.configService.get('imageAesKey')
const aesKey = this.resolveAesKey(aesKeyRaw) const aesKey = this.resolveAesKey(aesKeyRaw)
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey) const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
const ext = this.detectImageExtension(decrypted) || '.jpg' const ext = this.detectImageExtension(decrypted) || '.jpg'
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId) const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
await writeFile(outputPath, decrypted) await writeFile(outputPath, decrypted)
this.logInfo('解密成功', { outputPath, size: decrypted.length })
const isThumb = this.isThumbnailPath(datPath) const isThumb = this.isThumbnailPath(datPath)
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath) this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
@@ -209,6 +256,7 @@ export class ImageDecryptService {
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb }
} catch (e) { } catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
@@ -233,7 +281,7 @@ export class ImageDecryptService {
if (this.isAccountDir(entryPath)) return entryPath if (this.isAccountDir(entryPath)) return entryPath
} }
} }
} catch {} } catch { }
return null return null
} }
@@ -474,7 +522,7 @@ export class ImageDecryptService {
if (!hasUpdate) return if (!hasUpdate) return
this.updateFlags.set(cacheKey, true) this.updateFlags.set(cacheKey, true)
this.emitImageUpdate(payload, cacheKey) this.emitImageUpdate(payload, cacheKey)
}).catch(() => {}) }).catch(() => { })
} }
private looksLikeMd5(value: string): boolean { private looksLikeMd5(value: string): boolean {
@@ -863,6 +911,8 @@ export class ImageDecryptService {
} }
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`) const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
if (existsSync(thumbPath)) return thumbPath if (existsSync(thumbPath)) return thumbPath
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
if (!preferHd) { if (!preferHd) {
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`) const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
if (existsSync(hdPath)) return hdPath if (existsSync(hdPath)) return hdPath
@@ -960,8 +1010,9 @@ export class ImageDecryptService {
const lower = entry.toLowerCase() const lower = entry.toLowerCase()
if (!lower.endsWith('.dat')) continue if (!lower.endsWith('.dat')) continue
if (this.isThumbnailDat(lower)) continue if (this.isThumbnailDat(lower)) continue
if (!this.hasXVariant(lower.slice(0, -4))) continue
const baseLower = lower.slice(0, -4) const baseLower = lower.slice(0, -4)
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
if (!this.hasXVariant(baseLower)) continue
if (this.normalizeDatBase(baseLower) !== target) continue if (this.normalizeDatBase(baseLower) !== target) continue
return join(dirPath, entry) return join(dirPath, entry)
} }
@@ -973,6 +1024,7 @@ export class ImageDecryptService {
if (!lower.endsWith('.dat')) return false if (!lower.endsWith('.dat')) return false
if (this.isThumbnailDat(lower)) return false if (this.isThumbnailDat(lower)) return false
const baseLower = lower.slice(0, -4) const baseLower = lower.slice(0, -4)
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
return this.hasXVariant(baseLower) return this.hasXVariant(baseLower)
} }
@@ -1332,10 +1384,10 @@ export class ImageDecryptService {
keyCount.set(key, (keyCount.get(key) || 0) + 1) keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++ filesChecked++
} }
} catch {} } catch { }
} }
} }
} catch {} } catch { }
} }
scanDir(dirPath) scanDir(dirPath)

View File

@@ -1,9 +1,10 @@
import { app } from 'electron' import { app } from 'electron'
import { join, dirname, basename } from 'path' 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 { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import crypto from 'crypto' import crypto from 'crypto'
import os from 'os'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -57,18 +58,94 @@ export class KeyService {
private readonly ERROR_SUCCESS = 0 private readonly ERROR_SUCCESS = 0
private getDllPath(): string { private getDllPath(): string {
const resourcesPath = app.isPackaged const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') // 候选路径列表
return join(resourcesPath, 'wx_key.dll') 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 { private ensureLoaded(): boolean {
if (this.initialized) return true if (this.initialized) return true
let dllPath = ''
try { try {
this.koffi = require('koffi') this.koffi = require('koffi')
const dllPath = this.getDllPath() dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
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.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
@@ -80,7 +157,14 @@ export class KeyService {
this.initialized = true this.initialized = true
return true return true
} catch (e) { } 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 return false
} }
} }
@@ -831,17 +915,40 @@ export class KeyService {
return buffer.subarray(0, bytesRead[0]) 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 if (!this.ensureKernel32()) return null
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid) const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
if (!hProcess) return null if (!hProcess) return null
try { try {
const regions = this.getMemoryRegions(hProcess) const allRegions = this.getMemoryRegions(hProcess)
const chunkSize = 4 * 1024 * 1024
// 优化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 const overlap = 65
for (const [baseAddress, regionSize] of regions) { let currentRegion = 0
if (regionSize > 100 * 1024 * 1024) continue
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 offset = 0
let trailing: Buffer | null = null let trailing: Buffer | null = null
while (offset < regionSize) { while (offset < regionSize) {
@@ -896,6 +1003,9 @@ export class KeyService {
trailing = dataToScan.subarray(start < 0 ? 0 : start) trailing = dataToScan.subarray(start < 0 ? 0 : start)
offset += currentChunkSize offset += currentChunkSize
} }
// 更新已处理字节数
processedBytes += regionSize
} }
return null return null
} finally { } finally {
@@ -933,7 +1043,9 @@ export class KeyService {
if (!pid) return { success: false, error: '未检测到微信进程' } if (!pid) return { success: false, error: '未检测到微信进程' }
onProgress?.('正在扫描内存获取 AES 密钥...') 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) { if (!aesKey) {
return { return {
success: false, success: false,

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) })
}
})
}

2721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.0.4", "version": "1.1.0",
"description": "WeFlow - 微信聊天记录查看工具", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"scripts": { "scripts": {
"postinstall": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node", "rebuild": "echo 'No native modules to rebuild'",
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build && electron-builder", "build": "vue-tsc && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
@@ -14,8 +14,6 @@
"electron:build": "npm run build" "electron:build": "npm run build"
}, },
"dependencies": { "dependencies": {
"@lancedb/lancedb": "^0.23.1-beta.1",
"@lancedb/lancedb-win32-x64-msvc": "^0.22.3",
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
"echarts": "^5.5.1", "echarts": "^5.5.1",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
@@ -27,7 +25,6 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.9.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"onnxruntime-node": "^1.16.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
@@ -51,10 +48,6 @@
}, },
"build": { "build": {
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"asarUnpack": [
"**/node_modules/@lancedb/lancedb/**/*",
"**/node_modules/onnxruntime-node/**/*"
],
"publish": { "publish": {
"provider": "github", "provider": "github",
"releaseType": "release" "releaseType": "release"

Binary file not shown.

View File

@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow' import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage' import AgreementPage from './pages/AgreementPage'
@@ -308,7 +309,8 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <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="/group-analytics" element={<GroupAnalyticsPage />} />
<Route path="/annual-report" element={<AnnualReportPage />} /> <Route path="/annual-report" element={<AnnualReportPage />} />
<Route path="/annual-report/view" element={<AnnualReportWindow />} /> <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,10 +1,12 @@
import { useState, useEffect } from 'react' 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 { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import { useAnalyticsStore } from '../stores/analyticsStore' import { useAnalyticsStore } from '../stores/analyticsStore'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnalyticsPage.scss' import './AnalyticsPage.scss'
import './DataManagementPage.scss' import './DataManagementPage.scss'
import { Avatar } from '../components/Avatar'
function AnalyticsPage() { function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
@@ -28,7 +30,7 @@ function AnalyticsPage() {
try { try {
setLoadingStatus('正在统计消息数据...') setLoadingStatus('正在统计消息数据...')
const statsResult = await window.electronAPI.analytics.getOverallStatistics() const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
if (statsResult.success && statsResult.data) { if (statsResult.success && statsResult.data) {
setStatistics(statsResult.data) setStatistics(statsResult.data)
} else { } 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) const handleRefresh = () => loadData(true)
@@ -289,7 +296,7 @@ function AnalyticsPage() {
<div key={contact.username} className="ranking-item"> <div key={contact.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span> <span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar"> <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>} {index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div> </div>
<div className="contact-info"> <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; height: 6px;
opacity: 0.5; opacity: 0.5;
} }
50% { 50% {
height: 16px; height: 16px;
opacity: 1; opacity: 1;
} }
100% { 100% {
height: 6px; height: 6px;
opacity: 0.5; opacity: 0.5;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' 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 { 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 { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis' import { getEmojiPath } from 'wechat-emojis'
@@ -23,65 +24,10 @@ interface SessionDetail {
messageTables: { dbName: string; tableName: string; count: number }[] messageTables: { dbName: string; tableName: string; count: number }[]
} }
// 全局头像加载队列管理器(限制并发,避免卡顿) // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
class AvatarLoadQueue { // 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = [] import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
private loading = new Set<string>() import { Avatar } from '../components/Avatar'
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()
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染) // 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
// 会话项组件(使用 memo 优化,避免不必要的重渲染) // 会话项组件(使用 memo 优化,避免不必要的重渲染)
@@ -107,7 +53,12 @@ const SessionItem = React.memo(function SessionItem({
className={`session-item ${isActive ? 'active' : ''}`} className={`session-item ${isActive ? 'active' : ''}`}
onClick={() => onSelect(session)} 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-info">
<div className="session-top"> <div className="session-top">
<span className="session-name">{session.displayName || session.username}</span> <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) { function ChatPage(_props: ChatPageProps) {
const { const {
@@ -380,10 +229,8 @@ function ChatPage(_props: ChatPageProps) {
// 确保 nextSessions 也是数组 // 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) { if (Array.isArray(nextSessions)) {
setSessions(nextSessions) setSessions(nextSessions)
// 延迟启动联系人信息加载,确保UI先渲染完成 // 立即启动联系人信息加载,不再延迟 500ms
setTimeout(() => {
void enrichSessionsContactInfo(nextSessions) void enrichSessionsContactInfo(nextSessions)
}, 500)
} else { } else {
console.error('mergeSessions returned non-array:', nextSessions) console.error('mergeSessions returned non-array:', nextSessions)
setSessions(sessionsArray) setSessions(sessionsArray)
@@ -420,8 +267,7 @@ function ChatPage(_props: ChatPageProps) {
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`) console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now() const totalStart = performance.now()
// 延迟启动,等待UI渲染完成 // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
await new Promise(resolve => setTimeout(resolve, 500))
// 检查是否被取消 // 检查是否被取消
if (enrichCancelledRef.current) { if (enrichCancelledRef.current) {
@@ -430,8 +276,8 @@ function ChatPage(_props: ChatPageProps) {
} }
try { 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) { if (needEnrich.length === 0) {
console.log('[性能监控] 所有联系人信息已缓存,跳过加载') console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
isEnrichingRef.current = false isEnrichingRef.current = false
@@ -584,9 +430,15 @@ function ChatPage(_props: ChatPageProps) {
} }
if (result.success && result.contacts) { if (result.success && result.contacts) {
// 将更新加入队列,而不是立即更新 // 将更新加入队列,用于侧边栏更新
for (const [username, contact] of Object.entries(result.contacts)) { for (const [username, contact] of Object.entries(result.contacts)) {
contactUpdateQueueRef.current.set(username, contact) contactUpdateQueueRef.current.set(username, contact)
// 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用
senderAvatarCache.set(username, {
avatarUrl: contact.avatarUrl,
displayName: contact.displayName
})
} }
// 触发批量更新 // 触发批量更新
flushContactUpdates() flushContactUpdates()
@@ -660,6 +512,31 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) 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(() => { requestAnimationFrame(() => {
if (messageListRef.current) { if (messageListRef.current) {
@@ -668,6 +545,27 @@ function ChatPage(_props: ChatPageProps) {
}) })
} else { } else {
appendMessages(result.messages, true) 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) { if (firstMsgEl && listEl) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -1101,8 +999,10 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
)} )}
{/* ... (previous content) ... */}
{isLoadingSessions ? ( {isLoadingSessions ? (
<div className="loading-sessions"> <div className="loading-sessions">
{/* ... (skeleton items) ... */}
{[1, 2, 3, 4, 5].map(i => ( {[1, 2, 3, 4, 5].map(i => (
<div key={i} className="skeleton-item"> <div key={i} className="skeleton-item">
<div className="skeleton-avatar" /> <div className="skeleton-avatar" />
@@ -1118,12 +1018,10 @@ function ChatPage(_props: ChatPageProps) {
className="session-list" className="session-list"
ref={sessionListRef} ref={sessionListRef}
onScroll={() => { onScroll={() => {
// 标记正在滚动,暂停联系人信息加载
isScrollingRef.current = true isScrollingRef.current = true
if (sessionScrollTimeoutRef.current) { if (sessionScrollTimeoutRef.current) {
clearTimeout(sessionScrollTimeoutRef.current) clearTimeout(sessionScrollTimeoutRef.current)
} }
// 滚动结束后200ms才认为滚动停止
sessionScrollTimeoutRef.current = window.setTimeout(() => { sessionScrollTimeoutRef.current = window.setTimeout(() => {
isScrollingRef.current = false isScrollingRef.current = false
sessionScrollTimeoutRef.current = null sessionScrollTimeoutRef.current = null
@@ -1147,6 +1045,8 @@ function ChatPage(_props: ChatPageProps) {
<p className="hint"></p> <p className="hint"></p>
</div> </div>
)} )}
</div> </div>
{/* 拖动调节条 */} {/* 拖动调节条 */}
@@ -1157,7 +1057,12 @@ function ChatPage(_props: ChatPageProps) {
{currentSession ? ( {currentSession ? (
<> <>
<div className="message-header"> <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"> <div className="header-info">
<h3>{currentSession.displayName || currentSession.username}</h3> <h3>{currentSession.displayName || currentSession.username}</h3>
{isGroupChat(currentSession.username) && ( {isGroupChat(currentSession.username) && (
@@ -1767,13 +1672,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
</button> </button>
)} )}
</div> </div>
{showImagePreview && ( {showImagePreview && createPortal(
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}> <div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} /> <img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}> <button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
<X size={16} /> <X size={16} />
</button> </button>
</div> </div>,
document.body
)} )}
</> </>
) )
@@ -1920,11 +1826,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
)} )}
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}> <div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
<div className="bubble-avatar"> <div className="bubble-avatar">
{avatarUrl ? ( <Avatar
<img src={avatarUrl} alt="" /> src={avatarUrl}
) : ( name={!isSent ? (isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : '我'}
<span className="avatar-letter">{avatarLetter}</span> 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>
<div className="bubble-body"> <div className="bubble-body">
{/* 群聊中显示发送者名称 */} {/* 群聊中显示发送者名称 */}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-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 ReactECharts from 'echarts-for-react'
import DateRangePicker from '../components/DateRangePicker' import DateRangePicker from '../components/DateRangePicker'
import './GroupAnalyticsPage.scss' import './GroupAnalyticsPage.scss'
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
</button> </button>
<div className="modal-content"> <div className="modal-content">
<div className="member-avatar large"> <div className="member-avatar large">
{selectedMember.avatarUrl ? ( <Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
<img src={selectedMember.avatarUrl} alt="" />
) : (
<div className="avatar-placeholder"><User size={48} /></div>
)}
</div> </div>
<h3 className="member-display-name">{selectedMember.displayName}</h3> <h3 className="member-display-name">{selectedMember.displayName}</h3>
<div className="member-details"> <div className="member-details">
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
onClick={() => handleGroupSelect(group)} onClick={() => handleGroupSelect(group)}
> >
<div className="group-avatar"> <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>
<div className="group-info"> <div className="group-info">
<span className="group-name">{group.displayName}</span> <span className="group-name">{group.displayName}</span>
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
<div className="function-menu"> <div className="function-menu">
<div className="selected-group-info"> <div className="selected-group-info">
<div className="group-avatar large"> <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> </div>
<h2>{selectedGroup?.displayName}</h2> <h2>{selectedGroup?.displayName}</h2>
<p>{selectedGroup?.memberCount} </p> <p>{selectedGroup?.memberCount} </p>
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
{members.map(member => ( {members.map(member => (
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}> <div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
<div className="member-avatar"> <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> </div>
<span className="member-name">{member.displayName}</span> <span className="member-name">{member.displayName}</span>
</div> </div>
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
<div key={item.member.username} className="ranking-item"> <div key={item.member.username} className="ranking-item">
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span> <span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="contact-avatar"> <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>} {index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
</div> </div>
<div className="contact-info"> <div className="contact-info">

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ChatStatistics { interface ChatStatistics {
totalMessages: number totalMessages: number
@@ -49,7 +50,9 @@ interface AnalyticsState {
clearCache: () => void clearCache: () => void
} }
export const useAnalyticsStore = create<AnalyticsState>((set) => ({ export const useAnalyticsStore = create<AnalyticsState>()(
persist(
(set) => ({
statistics: null, statistics: null,
rankings: [], rankings: [],
timeDistribution: null, timeDistribution: null,
@@ -67,4 +70,9 @@ export const useAnalyticsStore = create<AnalyticsState>((set) => ({
isLoaded: false, isLoaded: false,
lastLoadTime: null 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 }> testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean> open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean> close: () => Promise<boolean>
} }
key: { key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
@@ -104,7 +105,7 @@ export interface ElectronAPI {
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
} }
analytics: { analytics: {
getOverallStatistics: () => Promise<{ getOverallStatistics: (force?: boolean) => Promise<{
success: boolean success: boolean
data?: { data?: {
totalMessages: number totalMessages: number

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

@@ -16,14 +16,7 @@ export default defineConfig({
} }
}, },
optimizeDeps: { optimizeDeps: {
exclude: [ exclude: []
'@lancedb/lancedb',
'@lancedb/lancedb-win32-x64-msvc',
'node-llama-cpp',
'onnxruntime-node',
'@xenova/transformers',
'@huggingface/transformers'
]
}, },
plugins: [ plugins: [
react(), react(),
@@ -37,12 +30,6 @@ export default defineConfig({
external: [ external: [
'better-sqlite3', 'better-sqlite3',
'koffi', 'koffi',
'node-llama-cpp',
'@lancedb/lancedb',
'@lancedb/lancedb-win32-x64-msvc',
'onnxruntime-node',
'@xenova/transformers',
'@huggingface/transformers',
'fsevents' 'fsevents'
] ]
} }
@@ -57,12 +44,6 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
external: [ external: [
'koffi', 'koffi',
'node-llama-cpp',
'@lancedb/lancedb',
'@lancedb/lancedb-win32-x64-msvc',
'onnxruntime-node',
'@xenova/transformers',
'@huggingface/transformers',
'fsevents' 'fsevents'
], ],
output: { output: {
@@ -73,30 +54,6 @@ export default defineConfig({
} }
} }
}, },
{
entry: 'electron/cloneEmbeddingWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'koffi',
'node-llama-cpp',
'@lancedb/lancedb',
'@lancedb/lancedb-win32-x64-msvc',
'onnxruntime-node',
'@xenova/transformers',
'@huggingface/transformers',
'fsevents'
],
output: {
entryFileNames: 'cloneEmbeddingWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{ {
entry: 'electron/imageSearchWorker.ts', entry: 'electron/imageSearchWorker.ts',
vite: { vite: {
@@ -111,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', entry: 'electron/preload.ts',
onstart(options) { onstart(options) {

1
wx_key Submodule

Submodule wx_key added at 40be59dc47