mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
This commit is contained in:
1
echotrace
Submodule
1
echotrace
Submodule
Submodule echotrace added at 98280f0d0d
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 密钥获取
|
// 密钥获取
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
1317
electron/services/wcdbCore.ts
Normal file
1317
electron/services/wcdbCore.ts
Normal file
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
122
electron/wcdbWorker.ts
Normal 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
2721
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@@ -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.
@@ -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 />} />
|
||||||
|
|||||||
79
src/components/Avatar.scss
Normal file
79
src/components/Avatar.scss
Normal 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
129
src/components/Avatar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -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">
|
||||||
|
|||||||
119
src/pages/AnalyticsWelcomePage.scss
Normal file
119
src/pages/AnalyticsWelcomePage.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
63
src/pages/AnalyticsWelcomePage.tsx
Normal 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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
{/* 群聊中显示发送者名称 */}
|
{/* 群聊中显示发送者名称 */}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
3
src/types/electron.d.ts
vendored
3
src/types/electron.d.ts
vendored
@@ -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
|
||||||
|
|||||||
74
src/utils/AvatarLoadQueue.ts
Normal file
74
src/utils/AvatarLoadQueue.ts
Normal 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()
|
||||||
@@ -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
1
wx_key
Submodule
Submodule wx_key added at 40be59dc47
Reference in New Issue
Block a user