mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16: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')
|
||||
}
|
||||
|
||||
function isHdDat(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||
return base.endsWith('_hd') || base.endsWith('_h')
|
||||
}
|
||||
|
||||
function walkForDat(
|
||||
root: string,
|
||||
datName: string,
|
||||
@@ -101,6 +107,8 @@ function walkForDat(
|
||||
if (!isLikelyImageDatBase(baseLower)) continue
|
||||
if (!hasXVariant(baseLower)) continue
|
||||
if (!matchesDatName(lower, datName)) continue
|
||||
// 排除高清图片格式 (_hd, _h)
|
||||
if (isHdDat(lower)) continue
|
||||
matchedBases.add(baseLower)
|
||||
const isThumb = isThumbnailDat(lower)
|
||||
if (!allowThumbnail && isThumb) continue
|
||||
|
||||
@@ -382,6 +382,8 @@ function registerIpcHandlers() {
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 聊天相关
|
||||
ipcMain.handle('chat:connect', async () => {
|
||||
return chatService.connect()
|
||||
@@ -468,8 +470,8 @@ function registerIpcHandlers() {
|
||||
})
|
||||
|
||||
// 数据分析相关
|
||||
ipcMain.handle('analytics:getOverallStatistics', async () => {
|
||||
return analyticsService.getOverallStatistics()
|
||||
ipcMain.handle('analytics:getOverallStatistics', async (_, force?: boolean) => {
|
||||
return analyticsService.getOverallStatistics(force)
|
||||
})
|
||||
|
||||
ipcMain.handle('analytics:getContactRankings', async (_, limit?: number) => {
|
||||
|
||||
@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
|
||||
open: (dbPath: string, hexKey: string, wxid: string) =>
|
||||
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
|
||||
close: () => ipcRenderer.invoke('wcdb:close')
|
||||
close: () => ipcRenderer.invoke('wcdb:close'),
|
||||
|
||||
},
|
||||
|
||||
// 密钥获取
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ConfigService } from './config'
|
||||
import { wcdbService } from './wcdbService'
|
||||
import { join } from 'path'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { app } from 'electron'
|
||||
|
||||
export interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -253,15 +256,31 @@ class AnalyticsService {
|
||||
sessionIds: string[],
|
||||
beginTimestamp = 0,
|
||||
endTimestamp = 0,
|
||||
window?: any
|
||||
window?: any,
|
||||
force = false
|
||||
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
|
||||
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
|
||||
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
|
||||
if (force) {
|
||||
if (this.aggregateCache) this.aggregateCache = null
|
||||
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
|
||||
}
|
||||
|
||||
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
|
||||
return { success: true, data: this.aggregateCache.data, source: 'cache' }
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试从文件加载缓存
|
||||
if (!force) {
|
||||
const fileCache = await this.loadCacheFromFile()
|
||||
if (fileCache && fileCache.key === cacheKey) {
|
||||
this.aggregateCache = fileCache
|
||||
return { success: true, data: fileCache.data, source: 'file-cache' }
|
||||
}
|
||||
}
|
||||
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
return this.aggregatePromise.promise
|
||||
}
|
||||
@@ -291,7 +310,12 @@ class AnalyticsService {
|
||||
|
||||
this.aggregatePromise = { key: cacheKey, promise }
|
||||
try {
|
||||
return await promise
|
||||
const result = await promise
|
||||
// 如果计算成功,同时写入此文件缓存
|
||||
if (result.success && result.data && result.source !== 'cache') {
|
||||
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
|
||||
}
|
||||
return result
|
||||
} finally {
|
||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||
this.aggregatePromise = null
|
||||
@@ -299,6 +323,25 @@ class AnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
private getCacheFilePath(): string {
|
||||
return join(app.getPath('userData'), 'analytics_cache.json')
|
||||
}
|
||||
|
||||
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||
try {
|
||||
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
|
||||
return JSON.parse(raw)
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
private async saveCacheToFile(data: any) {
|
||||
try {
|
||||
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
|
||||
} catch (e) {
|
||||
console.error('保存统计缓存失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAggregateSessions(
|
||||
sessions: Record<string, any> | undefined,
|
||||
idMap: Record<string, string> | undefined
|
||||
@@ -326,7 +369,7 @@ class AnalyticsService {
|
||||
void results
|
||||
}
|
||||
|
||||
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||
@@ -340,7 +383,7 @@ class AnalyticsService {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
this.setProgress(win, '正在执行原生数据聚合...', 30)
|
||||
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
|
||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error || '聚合统计失败' }
|
||||
@@ -458,8 +501,8 @@ class AnalyticsService {
|
||||
|
||||
const d = result.data
|
||||
|
||||
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
|
||||
// 前端期望 1=Mon...7=Sun
|
||||
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
|
||||
// 前端期望 1=周一...7=周日
|
||||
const weekdayDistribution: Record<number, number> = {}
|
||||
for (const [w, count] of Object.entries(d.weekday)) {
|
||||
const sqliteW = parseInt(w, 10)
|
||||
|
||||
@@ -667,24 +667,24 @@ class ChatService {
|
||||
|
||||
const messages: Message[] = []
|
||||
for (const row of rows) {
|
||||
const content = this.decodeMessageContent(
|
||||
this.getRowField(row, [
|
||||
'message_content',
|
||||
'messageContent',
|
||||
'content',
|
||||
'msg_content',
|
||||
'msgContent',
|
||||
'WCDB_CT_message_content',
|
||||
'WCDB_CT_messageContent'
|
||||
]),
|
||||
this.getRowField(row, [
|
||||
'compress_content',
|
||||
'compressContent',
|
||||
'compressed_content',
|
||||
'WCDB_CT_compress_content',
|
||||
'WCDB_CT_compressContent'
|
||||
])
|
||||
)
|
||||
const rawMessageContent = this.getRowField(row, [
|
||||
'message_content',
|
||||
'messageContent',
|
||||
'content',
|
||||
'msg_content',
|
||||
'msgContent',
|
||||
'WCDB_CT_message_content',
|
||||
'WCDB_CT_messageContent'
|
||||
]);
|
||||
const rawCompressContent = this.getRowField(row, [
|
||||
'compress_content',
|
||||
'compressContent',
|
||||
'compressed_content',
|
||||
'WCDB_CT_compress_content',
|
||||
'WCDB_CT_compressContent'
|
||||
]);
|
||||
|
||||
const content = this.decodeMessageContent(rawMessageContent, rawCompressContent);
|
||||
const localType = this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 1)
|
||||
const isSendRaw = this.getRowField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
|
||||
let isSend = isSendRaw === null ? null : parseInt(isSendRaw, 10)
|
||||
@@ -696,6 +696,16 @@ class ChatService {
|
||||
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0
|
||||
if (isSend === null) {
|
||||
isSend = expectedIsSend
|
||||
// [DEBUG] Issue #34: 记录 isSend 推断过程
|
||||
if (expectedIsSend === 0 && localType === 1) {
|
||||
// 仅在被判为接收且是文本消息时记录,避免刷屏
|
||||
// console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`)
|
||||
}
|
||||
}
|
||||
} else if (senderUsername && !myWxid) {
|
||||
// [DEBUG] Issue #34: 未配置 myWxid,无法判断是否发送
|
||||
if (messages.length < 5) {
|
||||
console.warn(`[ChatService] Warning: myWxid not set. Cannot determine if message is sent by me. sender=${senderUsername}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,9 +1491,9 @@ class ChatService {
|
||||
*/
|
||||
private decodeMessageContent(messageContent: any, compressContent: any): string {
|
||||
// 优先使用 compress_content
|
||||
let content = this.decodeMaybeCompressed(compressContent)
|
||||
let content = this.decodeMaybeCompressed(compressContent, 'compress_content')
|
||||
if (!content || content.length === 0) {
|
||||
content = this.decodeMaybeCompressed(messageContent)
|
||||
content = this.decodeMaybeCompressed(messageContent, 'message_content')
|
||||
}
|
||||
return content
|
||||
}
|
||||
@@ -1491,12 +1501,14 @@ class ChatService {
|
||||
/**
|
||||
* 尝试解码可能压缩的内容
|
||||
*/
|
||||
private decodeMaybeCompressed(raw: any): string {
|
||||
private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): string {
|
||||
if (!raw) return ''
|
||||
|
||||
// console.log(`[ChatService] Decoding ${fieldName}: type=${typeof raw}`, raw)
|
||||
|
||||
// 如果是 Buffer/Uint8Array
|
||||
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
|
||||
return this.decodeBinaryContent(Buffer.from(raw))
|
||||
return this.decodeBinaryContent(Buffer.from(raw), String(raw))
|
||||
}
|
||||
|
||||
// 如果是字符串
|
||||
@@ -1507,7 +1519,9 @@ class ChatService {
|
||||
if (this.looksLikeHex(raw)) {
|
||||
const bytes = Buffer.from(raw, 'hex')
|
||||
if (bytes.length > 0) {
|
||||
return this.decodeBinaryContent(bytes)
|
||||
const result = this.decodeBinaryContent(bytes, raw)
|
||||
// console.log(`[ChatService] HEX decoded result: ${result}`)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,7 +1529,7 @@ class ChatService {
|
||||
if (this.looksLikeBase64(raw)) {
|
||||
try {
|
||||
const bytes = Buffer.from(raw, 'base64')
|
||||
return this.decodeBinaryContent(bytes)
|
||||
return this.decodeBinaryContent(bytes, raw)
|
||||
} catch { }
|
||||
}
|
||||
|
||||
@@ -1529,7 +1543,7 @@ class ChatService {
|
||||
/**
|
||||
* 解码二进制内容(处理 zstd 压缩)
|
||||
*/
|
||||
private decodeBinaryContent(data: Buffer): string {
|
||||
private decodeBinaryContent(data: Buffer, fallbackValue?: string): string {
|
||||
if (data.length === 0) return ''
|
||||
|
||||
try {
|
||||
@@ -1556,10 +1570,16 @@ class ChatService {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
|
||||
// 如果提供了 fallbackValue,且解码结果看起来像二进制垃圾,则返回 fallbackValue
|
||||
if (fallbackValue && replacementCount > 0) {
|
||||
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`)
|
||||
return fallbackValue
|
||||
}
|
||||
|
||||
// 尝试 latin1 解码
|
||||
return data.toString('latin1')
|
||||
} catch {
|
||||
return ''
|
||||
return fallbackValue || ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@ class ExportService {
|
||||
|
||||
const [displayNames, avatarUrls] = await Promise.all([
|
||||
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) {
|
||||
@@ -590,7 +590,11 @@ class ExportService {
|
||||
}
|
||||
}
|
||||
if (!data) continue
|
||||
const finalMime = mime || this.inferImageMime(fileInfo.ext)
|
||||
|
||||
// 优先使用内容检测出的 MIME 类型
|
||||
const detectedMime = this.detectMimeType(data)
|
||||
const finalMime = detectedMime || mime || this.inferImageMime(fileInfo.ext)
|
||||
|
||||
const base64 = data.toString('base64')
|
||||
result.set(member.username, `data:${finalMime};base64,${base64}`)
|
||||
} catch {
|
||||
@@ -601,6 +605,39 @@ class ExportService {
|
||||
return result
|
||||
}
|
||||
|
||||
private detectMimeType(buffer: Buffer): string | null {
|
||||
if (buffer.length < 4) return null
|
||||
|
||||
// PNG: 89 50 4E 47
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) {
|
||||
return 'image/png'
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38
|
||||
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
||||
return 'image/gif'
|
||||
}
|
||||
|
||||
// WEBP: RIFF ... WEBP
|
||||
if (buffer.length >= 12 &&
|
||||
buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
return 'image/webp'
|
||||
}
|
||||
|
||||
// BMP: 42 4D
|
||||
if (buffer[0] === 0x42 && buffer[1] === 0x4D) {
|
||||
return 'image/bmp'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private inferImageMime(ext: string): string {
|
||||
switch (ext.toLowerCase()) {
|
||||
case '.png':
|
||||
@@ -659,7 +696,8 @@ class ExportService {
|
||||
const chatLabMessages: ChatLabMessage[] = allMessages.map((msg) => {
|
||||
const memberInfo = collected.memberSet.get(msg.senderUsername)?.member || {
|
||||
platformId: msg.senderUsername,
|
||||
accountName: msg.senderUsername
|
||||
accountName: msg.senderUsername,
|
||||
groupNickname: undefined
|
||||
}
|
||||
return {
|
||||
sender: msg.senderUsername,
|
||||
@@ -811,7 +849,8 @@ class ExportService {
|
||||
displayName: sessionInfo.displayName,
|
||||
type: isGroup ? '群聊' : '私聊',
|
||||
lastTimestamp: collected.lastTime,
|
||||
messageCount: allMessages.length
|
||||
messageCount: allMessages.length,
|
||||
avatar: undefined as string | undefined
|
||||
},
|
||||
messages: allMessages
|
||||
}
|
||||
|
||||
@@ -181,6 +181,8 @@ class GroupAnalyticsService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
|
||||
try {
|
||||
const conn = await this.ensureConnected()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app, BrowserWindow } from 'electron'
|
||||
import { basename, dirname, extname, join } from 'path'
|
||||
import { pathToFileURL } from 'url'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'fs'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import crypto from 'crypto'
|
||||
import { Worker } from 'worker_threads'
|
||||
@@ -32,11 +32,45 @@ export class ImageDecryptService {
|
||||
|
||||
private logInfo(message: string, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
if (meta) {
|
||||
console.info(message, meta)
|
||||
} else {
|
||||
console.info(message)
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private logError(message: string, error?: unknown, meta?: Record<string, unknown>): void {
|
||||
if (!this.configService.get('logEnabled')) return
|
||||
const timestamp = new Date().toISOString()
|
||||
const errorStr = error ? ` Error: ${String(error)}` : ''
|
||||
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||
const logLine = `[${timestamp}] [ImageDecrypt] ERROR: ${message}${errorStr}${metaStr}\n`
|
||||
|
||||
// 同时输出到控制台
|
||||
console.error(message, error, meta)
|
||||
|
||||
// 写入日志文件
|
||||
this.writeLog(logLine)
|
||||
}
|
||||
|
||||
private writeLog(line: string): void {
|
||||
try {
|
||||
const logDir = join(app.getPath('userData'), 'logs')
|
||||
if (!existsSync(logDir)) {
|
||||
mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
appendFileSync(join(logDir, 'wcdb.log'), line, { encoding: 'utf8' })
|
||||
} catch (err) {
|
||||
console.error('写入日志失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCachedImage(payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }): Promise<DecryptResult & { hasUpdate?: boolean }> {
|
||||
@@ -49,6 +83,7 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const cached = this.resolvedCache.get(key)
|
||||
if (cached && existsSync(cached) && this.isImageFile(cached)) {
|
||||
this.logInfo('缓存命中(从Map)', { key, path: cached, isThumb: this.isThumbnailPath(cached) })
|
||||
const dataUrl = this.fileToDataUrl(cached)
|
||||
const isThumb = this.isThumbnailPath(cached)
|
||||
const hasUpdate = isThumb ? (this.updateFlags.get(key) ?? false) : false
|
||||
@@ -68,6 +103,7 @@ export class ImageDecryptService {
|
||||
for (const key of cacheKeys) {
|
||||
const existing = this.findCachedOutput(key, false, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('缓存命中(文件系统)', { key, path: existing, isThumb: this.isThumbnailPath(existing) })
|
||||
this.cacheResolvedPaths(key, payload.imageMd5, payload.imageDatName, existing)
|
||||
const dataUrl = this.fileToDataUrl(existing)
|
||||
const isThumb = this.isThumbnailPath(existing)
|
||||
@@ -81,6 +117,7 @@ export class ImageDecryptService {
|
||||
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate }
|
||||
}
|
||||
}
|
||||
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到缓存图片' }
|
||||
}
|
||||
|
||||
@@ -120,15 +157,18 @@ export class ImageDecryptService {
|
||||
payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean },
|
||||
cacheKey: string
|
||||
): Promise<DecryptResult> {
|
||||
this.logInfo('开始解密图片', { md5: payload.imageMd5, datName: payload.imageDatName, force: payload.force })
|
||||
try {
|
||||
const wxid = this.configService.get('myWxid')
|
||||
const dbPath = this.configService.get('dbPath')
|
||||
if (!wxid || !dbPath) {
|
||||
this.logError('配置缺失', undefined, { wxid: !!wxid, dbPath: !!dbPath })
|
||||
return { success: false, error: '未配置账号或数据库路径' }
|
||||
}
|
||||
|
||||
const accountDir = this.resolveAccountDir(dbPath, wxid)
|
||||
if (!accountDir) {
|
||||
this.logError('未找到账号目录', undefined, { dbPath, wxid })
|
||||
return { success: false, error: '未找到账号目录' }
|
||||
}
|
||||
|
||||
@@ -142,12 +182,16 @@ export class ImageDecryptService {
|
||||
|
||||
// 如果要求高清图但没找到,直接返回提示
|
||||
if (!datPath && payload.force) {
|
||||
this.logError('未找到高清图', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到高清图,请在微信中点开该图片查看后重试' }
|
||||
}
|
||||
if (!datPath) {
|
||||
this.logError('未找到DAT文件', undefined, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: '未找到图片文件' }
|
||||
}
|
||||
|
||||
this.logInfo('找到DAT文件', { datPath })
|
||||
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, datPath)
|
||||
const dataUrl = this.fileToDataUrl(datPath)
|
||||
@@ -160,6 +204,7 @@ export class ImageDecryptService {
|
||||
// 查找已缓存的解密文件
|
||||
const existing = this.findCachedOutput(cacheKey, payload.force, payload.sessionId)
|
||||
if (existing) {
|
||||
this.logInfo('找到已解密文件', { existing, isHd: this.isHdPath(existing) })
|
||||
const isHd = this.isHdPath(existing)
|
||||
// 如果要求高清但找到的是缩略图,继续解密高清图
|
||||
if (!(payload.force && !isHd)) {
|
||||
@@ -192,12 +237,14 @@ export class ImageDecryptService {
|
||||
const aesKeyRaw = this.configService.get('imageAesKey')
|
||||
const aesKey = this.resolveAesKey(aesKeyRaw)
|
||||
|
||||
this.logInfo('开始解密DAT文件', { datPath, xorKey, hasAesKey: !!aesKey })
|
||||
const decrypted = await this.decryptDatAuto(datPath, xorKey, aesKey)
|
||||
|
||||
const ext = this.detectImageExtension(decrypted) || '.jpg'
|
||||
|
||||
const outputPath = this.getCacheOutputPathFromDat(datPath, ext, payload.sessionId)
|
||||
await writeFile(outputPath, decrypted)
|
||||
this.logInfo('解密成功', { outputPath, size: decrypted.length })
|
||||
|
||||
const isThumb = this.isThumbnailPath(datPath)
|
||||
this.cacheResolvedPaths(cacheKey, payload.imageMd5, payload.imageDatName, outputPath)
|
||||
@@ -209,6 +256,7 @@ export class ImageDecryptService {
|
||||
this.emitCacheResolved(payload, cacheKey, localPath)
|
||||
return { success: true, localPath, isThumb }
|
||||
} catch (e) {
|
||||
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
|
||||
return { success: false, error: String(e) }
|
||||
}
|
||||
}
|
||||
@@ -233,7 +281,7 @@ export class ImageDecryptService {
|
||||
if (this.isAccountDir(entryPath)) return entryPath
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -474,7 +522,7 @@ export class ImageDecryptService {
|
||||
if (!hasUpdate) return
|
||||
this.updateFlags.set(cacheKey, true)
|
||||
this.emitImageUpdate(payload, cacheKey)
|
||||
}).catch(() => {})
|
||||
}).catch(() => { })
|
||||
}
|
||||
|
||||
private looksLikeMd5(value: string): boolean {
|
||||
@@ -863,6 +911,8 @@ export class ImageDecryptService {
|
||||
}
|
||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||
if (existsSync(thumbPath)) return thumbPath
|
||||
|
||||
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||
if (!preferHd) {
|
||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||
if (existsSync(hdPath)) return hdPath
|
||||
@@ -960,8 +1010,9 @@ export class ImageDecryptService {
|
||||
const lower = entry.toLowerCase()
|
||||
if (!lower.endsWith('.dat')) continue
|
||||
if (this.isThumbnailDat(lower)) continue
|
||||
if (!this.hasXVariant(lower.slice(0, -4))) continue
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
||||
if (!this.hasXVariant(baseLower)) continue
|
||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||
return join(dirPath, entry)
|
||||
}
|
||||
@@ -973,6 +1024,7 @@ export class ImageDecryptService {
|
||||
if (!lower.endsWith('.dat')) return false
|
||||
if (this.isThumbnailDat(lower)) return false
|
||||
const baseLower = lower.slice(0, -4)
|
||||
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
||||
return this.hasXVariant(baseLower)
|
||||
}
|
||||
|
||||
@@ -1214,7 +1266,7 @@ export class ImageDecryptService {
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
|
||||
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
|
||||
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
|
||||
return '.webp'
|
||||
}
|
||||
return null
|
||||
@@ -1332,10 +1384,10 @@ export class ImageDecryptService {
|
||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||
filesChecked++
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
scanDir(dirPath)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { app } from 'electron'
|
||||
import { join, dirname, basename } from 'path'
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
|
||||
import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
|
||||
import { execFile, spawn } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -57,18 +58,94 @@ export class KeyService {
|
||||
private readonly ERROR_SUCCESS = 0
|
||||
|
||||
private getDllPath(): string {
|
||||
const resourcesPath = app.isPackaged
|
||||
? join(process.resourcesPath, 'resources')
|
||||
: join(app.getAppPath(), 'resources')
|
||||
return join(resourcesPath, 'wx_key.dll')
|
||||
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
|
||||
|
||||
// 候选路径列表
|
||||
const candidates: string[] = []
|
||||
|
||||
// 1. 显式环境变量 (最高优先级)
|
||||
if (process.env.WX_KEY_DLL_PATH) {
|
||||
candidates.push(process.env.WX_KEY_DLL_PATH)
|
||||
}
|
||||
|
||||
if (isPackaged) {
|
||||
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
|
||||
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
|
||||
} else {
|
||||
// 开发环境
|
||||
const cwd = process.cwd()
|
||||
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
|
||||
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
|
||||
}
|
||||
|
||||
// 检查并返回第一个存在的路径
|
||||
for (const path of candidates) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没找到,返回最可能的路径以便报错信息有参考
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
// 检查路径是否为 UNC 路径或网络路径
|
||||
private isNetworkPath(path: string): boolean {
|
||||
// UNC 路径以 \\ 开头
|
||||
if (path.startsWith('\\\\')) {
|
||||
return true
|
||||
}
|
||||
// 检查是否为网络映射驱动器(简化检测:A: 表示驱动器)
|
||||
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
|
||||
// 但对于大多数 VM 共享场景,UNC 路径检测已足够
|
||||
return false
|
||||
}
|
||||
|
||||
// 将 DLL 复制到本地临时目录
|
||||
private localizeNetworkDll(originalPath: string): string {
|
||||
try {
|
||||
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
|
||||
if (!existsSync(tempDir)) {
|
||||
mkdirSync(tempDir, { recursive: true })
|
||||
}
|
||||
const localPath = join(tempDir, 'wx_key.dll')
|
||||
|
||||
// 检查是否已经有本地副本,如果有就使用它
|
||||
if (existsSync(localPath)) {
|
||||
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
|
||||
return localPath
|
||||
}
|
||||
|
||||
console.log(`检测到网络路径 DLL,正在复制到本地: ${originalPath} -> ${localPath}`)
|
||||
copyFileSync(originalPath, localPath)
|
||||
console.log('DLL 本地化成功')
|
||||
return localPath
|
||||
} catch (e) {
|
||||
console.error('DLL 本地化失败:', e)
|
||||
// 如果本地化失败,返回原路径
|
||||
return originalPath
|
||||
}
|
||||
}
|
||||
|
||||
private ensureLoaded(): boolean {
|
||||
if (this.initialized) return true
|
||||
|
||||
let dllPath = ''
|
||||
try {
|
||||
this.koffi = require('koffi')
|
||||
const dllPath = this.getDllPath()
|
||||
if (!existsSync(dllPath)) return false
|
||||
dllPath = this.getDllPath()
|
||||
|
||||
if (!existsSync(dllPath)) {
|
||||
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否为网络路径,如果是则本地化
|
||||
if (this.isNetworkPath(dllPath)) {
|
||||
console.log('检测到网络路径,将进行本地化处理')
|
||||
dllPath = this.localizeNetworkDll(dllPath)
|
||||
}
|
||||
|
||||
this.lib = this.koffi.load(dllPath)
|
||||
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
|
||||
@@ -80,7 +157,14 @@ export class KeyService {
|
||||
this.initialized = true
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('加载 wx_key.dll 失败:', e)
|
||||
const errorMsg = e instanceof Error ? e.message : String(e)
|
||||
const errorStack = e instanceof Error ? e.stack : ''
|
||||
console.error(`加载 wx_key.dll 失败`)
|
||||
console.error(` 路径: ${dllPath}`)
|
||||
console.error(` 错误: ${errorMsg}`)
|
||||
if (errorStack) {
|
||||
console.error(` 堆栈: ${errorStack}`)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -831,17 +915,40 @@ export class KeyService {
|
||||
return buffer.subarray(0, bytesRead[0])
|
||||
}
|
||||
|
||||
private async getAesKeyFromMemory(pid: number, ciphertext: Buffer): Promise<string | null> {
|
||||
private async getAesKeyFromMemory(
|
||||
pid: number,
|
||||
ciphertext: Buffer,
|
||||
onProgress?: (current: number, total: number, message: string) => void
|
||||
): Promise<string | null> {
|
||||
if (!this.ensureKernel32()) return null
|
||||
const hProcess = this.OpenProcess(this.PROCESS_ALL_ACCESS, false, pid)
|
||||
if (!hProcess) return null
|
||||
|
||||
try {
|
||||
const regions = this.getMemoryRegions(hProcess)
|
||||
const chunkSize = 4 * 1024 * 1024
|
||||
const allRegions = this.getMemoryRegions(hProcess)
|
||||
|
||||
// 优化1: 只保留小内存区域(< 10MB)- 密钥通常在小区域,可大幅减少扫描时间
|
||||
const filteredRegions = allRegions.filter(([_, size]) => size <= 10 * 1024 * 1024)
|
||||
|
||||
// 优化2: 优先级排序 - 按大小升序,先扫描小区域(密钥通常在较小区域)
|
||||
const sortedRegions = filteredRegions.sort((a, b) => a[1] - b[1])
|
||||
|
||||
// 优化3: 计算总字节数用于精确进度报告
|
||||
const totalBytes = sortedRegions.reduce((sum, [_, size]) => sum + size, 0)
|
||||
let processedBytes = 0
|
||||
|
||||
// 优化4: 减小分块大小到 1MB(参考 wx_key 项目)
|
||||
const chunkSize = 1 * 1024 * 1024
|
||||
const overlap = 65
|
||||
for (const [baseAddress, regionSize] of regions) {
|
||||
if (regionSize > 100 * 1024 * 1024) continue
|
||||
let currentRegion = 0
|
||||
|
||||
for (const [baseAddress, regionSize] of sortedRegions) {
|
||||
currentRegion++
|
||||
const progress = totalBytes > 0 ? Math.floor((processedBytes / totalBytes) * 100) : 0
|
||||
onProgress?.(progress, 100, `扫描内存 ${progress}% (${currentRegion}/${sortedRegions.length})`)
|
||||
|
||||
// 每个区域都让出主线程,确保UI流畅
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
let offset = 0
|
||||
let trailing: Buffer | null = null
|
||||
while (offset < regionSize) {
|
||||
@@ -896,6 +1003,9 @@ export class KeyService {
|
||||
trailing = dataToScan.subarray(start < 0 ? 0 : start)
|
||||
offset += currentChunkSize
|
||||
}
|
||||
|
||||
// 更新已处理字节数
|
||||
processedBytes += regionSize
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
@@ -933,7 +1043,9 @@ export class KeyService {
|
||||
if (!pid) return { success: false, error: '未检测到微信进程' }
|
||||
|
||||
onProgress?.('正在扫描内存获取 AES 密钥...')
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext)
|
||||
const aesKey = await this.getAesKeyFromMemory(pid, ciphertext, (current, total, msg) => {
|
||||
onProgress?.(`${msg} (${current}/${total})`)
|
||||
})
|
||||
if (!aesKey) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
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",
|
||||
"version": "1.0.4",
|
||||
"description": "WeFlow - 微信聊天记录查看工具",
|
||||
"version": "1.1.0",
|
||||
"description": "WeFlow",
|
||||
"main": "dist-electron/main.js",
|
||||
"author": "cc",
|
||||
"scripts": {
|
||||
"postinstall": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node",
|
||||
"rebuild": "electron-rebuild -f -w @lancedb/lancedb,onnxruntime-node",
|
||||
"postinstall": "echo 'No native modules to rebuild'",
|
||||
"rebuild": "echo 'No native modules to rebuild'",
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build && electron-builder",
|
||||
"preview": "vite preview",
|
||||
@@ -14,8 +14,6 @@
|
||||
"electron:build": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.23.1-beta.1",
|
||||
"@lancedb/lancedb-win32-x64-msvc": "^0.22.3",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"echarts": "^5.5.1",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -27,7 +25,6 @@
|
||||
"jszip": "^3.10.1",
|
||||
"koffi": "^2.9.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"onnxruntime-node": "^1.16.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-router-dom": "^7.1.1",
|
||||
@@ -51,10 +48,6 @@
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.WeFlow.app",
|
||||
"asarUnpack": [
|
||||
"**/node_modules/@lancedb/lancedb/**/*",
|
||||
"**/node_modules/onnxruntime-node/**/*"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "github",
|
||||
"releaseType": "release"
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ChatPage from './pages/ChatPage'
|
||||
import AnalyticsPage from './pages/AnalyticsPage'
|
||||
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
|
||||
import AnnualReportPage from './pages/AnnualReportPage'
|
||||
import AnnualReportWindow from './pages/AnnualReportWindow'
|
||||
import AgreementPage from './pages/AgreementPage'
|
||||
@@ -308,7 +309,8 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsWelcomePage />} />
|
||||
<Route path="/analytics/view" element={<AnalyticsPage />} />
|
||||
<Route path="/group-analytics" element={<GroupAnalyticsPage />} />
|
||||
<Route path="/annual-report" element={<AnnualReportPage />} />
|
||||
<Route path="/annual-report/view" element={<AnnualReportWindow />} />
|
||||
|
||||
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 { useLocation } from 'react-router-dom'
|
||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||
import { useThemeStore } from '../stores/themeStore'
|
||||
import './AnalyticsPage.scss'
|
||||
import './DataManagementPage.scss'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
function AnalyticsPage() {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
@@ -28,7 +30,7 @@ function AnalyticsPage() {
|
||||
|
||||
try {
|
||||
setLoadingStatus('正在统计消息数据...')
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatistics(statsResult.data)
|
||||
} else {
|
||||
@@ -55,7 +57,12 @@ function AnalyticsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadData() }, [])
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const force = location.state?.forceRefresh === true
|
||||
loadData(force)
|
||||
}, [location.state])
|
||||
|
||||
const handleRefresh = () => loadData(true)
|
||||
|
||||
@@ -289,7 +296,7 @@ function AnalyticsPage() {
|
||||
<div key={contact.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
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;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
50% {
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
height: 6px;
|
||||
opacity: 0.5;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useChatStore } from '../stores/chatStore'
|
||||
import type { ChatSession, Message } from '../types/models'
|
||||
import { getEmojiPath } from 'wechat-emojis'
|
||||
@@ -23,65 +24,10 @@ interface SessionDetail {
|
||||
messageTables: { dbName: string; tableName: string; count: number }[]
|
||||
}
|
||||
|
||||
// 全局头像加载队列管理器(限制并发,避免卡顿)
|
||||
class AvatarLoadQueue {
|
||||
private queue: Array<{ url: string; resolve: () => void; reject: () => void }> = []
|
||||
private loading = new Set<string>()
|
||||
private readonly maxConcurrent = 1 // 一次只加载1个头像,避免卡顿
|
||||
private readonly delayBetweenBatches = 100 // 批次间延迟100ms,给UI喘息时间
|
||||
|
||||
async enqueue(url: string): Promise<void> {
|
||||
// 如果已经在加载中,直接返回
|
||||
if (this.loading.has(url)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ url, resolve, reject })
|
||||
this.processQueue()
|
||||
})
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
// 如果已达到最大并发数,等待
|
||||
if (this.loading.size >= this.maxConcurrent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果队列为空,返回
|
||||
if (this.queue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 取出一个任务
|
||||
const task = this.queue.shift()
|
||||
if (!task) return
|
||||
|
||||
this.loading.add(task.url)
|
||||
|
||||
// 加载图片
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
this.loading.delete(task.url)
|
||||
task.resolve()
|
||||
// 延迟一下再处理下一个,避免一次性加载太多
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.onerror = () => {
|
||||
this.loading.delete(task.url)
|
||||
task.reject()
|
||||
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||
}
|
||||
img.src = task.url
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.queue = []
|
||||
this.loading.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const avatarLoadQueue = new AvatarLoadQueue()
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
// 全局头像加载队列管理器已移至 src/utils/AvatarLoadQueue.ts
|
||||
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
|
||||
// 头像组件 - 支持骨架屏加载和懒加载(优化:限制并发,使用 memo 避免不必要的重渲染)
|
||||
// 会话项组件(使用 memo 优化,避免不必要的重渲染)
|
||||
@@ -107,7 +53,12 @@ const SessionItem = React.memo(function SessionItem({
|
||||
className={`session-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => onSelect(session)}
|
||||
>
|
||||
<SessionAvatar session={session} size={48} />
|
||||
<Avatar
|
||||
src={session.avatarUrl}
|
||||
name={session.displayName || session.username}
|
||||
size={48}
|
||||
className={session.username.includes('@chatroom') ? 'group' : ''}
|
||||
/>
|
||||
<div className="session-info">
|
||||
<div className="session-top">
|
||||
<span className="session-name">{session.displayName || session.username}</span>
|
||||
@@ -138,109 +89,7 @@ const SessionItem = React.memo(function SessionItem({
|
||||
)
|
||||
})
|
||||
|
||||
const SessionAvatar = React.memo(function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) {
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const [shouldLoad, setShouldLoad] = useState(false)
|
||||
const [isInQueue, setIsInQueue] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isGroup = session.username.includes('@chatroom')
|
||||
|
||||
const getAvatarLetter = (): string => {
|
||||
const name = session.displayName || session.username
|
||||
if (!name) return '?'
|
||||
const chars = [...name]
|
||||
return chars[0] || '?'
|
||||
}
|
||||
|
||||
// 使用 Intersection Observer 实现懒加载(优化性能)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || shouldLoad || isInQueue) return
|
||||
if (!session.avatarUrl) {
|
||||
// 没有头像URL,不需要加载
|
||||
return
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting && !isInQueue) {
|
||||
// 加入加载队列,而不是立即加载
|
||||
setIsInQueue(true)
|
||||
avatarLoadQueue.enqueue(session.avatarUrl!).then(() => {
|
||||
setShouldLoad(true)
|
||||
}).catch(() => {
|
||||
setImageError(true)
|
||||
}).finally(() => {
|
||||
setIsInQueue(false)
|
||||
})
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
rootMargin: '50px' // 减少预加载距离,只提前50px
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(containerRef.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [session.avatarUrl, shouldLoad, isInQueue])
|
||||
|
||||
// 当 avatarUrl 变化时重置状态
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
setImageError(false)
|
||||
setShouldLoad(false)
|
||||
setIsInQueue(false)
|
||||
}, [session.avatarUrl])
|
||||
|
||||
// 检查图片是否已经从缓存加载完成
|
||||
useEffect(() => {
|
||||
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||
setImageLoaded(true)
|
||||
}
|
||||
}, [session.avatarUrl, shouldLoad])
|
||||
|
||||
const hasValidUrl = session.avatarUrl && !imageError && shouldLoad
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`session-avatar ${isGroup ? 'group' : ''} ${hasValidUrl && !imageLoaded ? 'loading' : ''}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{hasValidUrl ? (
|
||||
<>
|
||||
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={session.avatarUrl}
|
||||
alt=""
|
||||
className={imageLoaded ? 'loaded' : ''}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="avatar-letter">{getAvatarLetter()}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数,只在关键属性变化时重渲染
|
||||
return (
|
||||
prevProps.session.username === nextProps.session.username &&
|
||||
prevProps.session.displayName === nextProps.session.displayName &&
|
||||
prevProps.session.avatarUrl === nextProps.session.avatarUrl &&
|
||||
prevProps.size === nextProps.size
|
||||
)
|
||||
})
|
||||
|
||||
function ChatPage(_props: ChatPageProps) {
|
||||
const {
|
||||
@@ -380,10 +229,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
// 确保 nextSessions 也是数组
|
||||
if (Array.isArray(nextSessions)) {
|
||||
setSessions(nextSessions)
|
||||
// 延迟启动联系人信息加载,确保UI先渲染完成
|
||||
setTimeout(() => {
|
||||
void enrichSessionsContactInfo(nextSessions)
|
||||
}, 500)
|
||||
// 立即启动联系人信息加载,不再延迟 500ms
|
||||
void enrichSessionsContactInfo(nextSessions)
|
||||
} else {
|
||||
console.error('mergeSessions returned non-array:', nextSessions)
|
||||
setSessions(sessionsArray)
|
||||
@@ -420,8 +267,7 @@ function ChatPage(_props: ChatPageProps) {
|
||||
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
|
||||
const totalStart = performance.now()
|
||||
|
||||
// 延迟启动,等待UI渲染完成
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
// 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
|
||||
|
||||
// 检查是否被取消
|
||||
if (enrichCancelledRef.current) {
|
||||
@@ -430,8 +276,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
try {
|
||||
// 找出需要加载联系人信息的会话(没有缓存的)
|
||||
const needEnrich = sessions.filter(s => !s.avatarUrl && (!s.displayName || s.displayName === s.username))
|
||||
// 找出需要加载联系人信息的会话(没有头像或者没有显示名称的)
|
||||
const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username)
|
||||
if (needEnrich.length === 0) {
|
||||
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
|
||||
isEnrichingRef.current = false
|
||||
@@ -584,9 +430,15 @@ function ChatPage(_props: ChatPageProps) {
|
||||
}
|
||||
|
||||
if (result.success && result.contacts) {
|
||||
// 将更新加入队列,而不是立即更新
|
||||
// 将更新加入队列,用于侧边栏更新
|
||||
for (const [username, contact] of Object.entries(result.contacts)) {
|
||||
contactUpdateQueueRef.current.set(username, contact)
|
||||
|
||||
// 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用
|
||||
senderAvatarCache.set(username, {
|
||||
avatarUrl: contact.avatarUrl,
|
||||
displayName: contact.displayName
|
||||
})
|
||||
}
|
||||
// 触发批量更新
|
||||
flushContactUpdates()
|
||||
@@ -660,6 +512,31 @@ function ChatPage(_props: ChatPageProps) {
|
||||
if (result.success && result.messages) {
|
||||
if (offset === 0) {
|
||||
setMessages(result.messages)
|
||||
|
||||
// 预取发送者信息:在关闭加载遮罩前处理
|
||||
const unreadCount = session?.unreadCount ?? 0
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
if (isGroup && result.messages.length > 0) {
|
||||
const unknownSenders = [...new Set(result.messages
|
||||
.filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername))
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length} 个`)
|
||||
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
|
||||
const batchPromise = loadContactInfoBatch(unknownSenders)
|
||||
unknownSenders.forEach(username => {
|
||||
if (!senderAvatarLoading.has(username)) {
|
||||
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
|
||||
}
|
||||
})
|
||||
// 确保在请求完成后清理 loading 状态
|
||||
batchPromise.finally(() => {
|
||||
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 首次加载滚动到底部
|
||||
requestAnimationFrame(() => {
|
||||
if (messageListRef.current) {
|
||||
@@ -668,6 +545,27 @@ function ChatPage(_props: ChatPageProps) {
|
||||
})
|
||||
} else {
|
||||
appendMessages(result.messages, true)
|
||||
|
||||
// 加载更多也同样处理发送者信息预取
|
||||
const isGroup = sessionId.includes('@chatroom')
|
||||
if (isGroup) {
|
||||
const unknownSenders = [...new Set(result.messages
|
||||
.filter(m => m.isSend !== 1 && m.senderUsername && !senderAvatarCache.has(m.senderUsername))
|
||||
.map(m => m.senderUsername as string)
|
||||
)]
|
||||
if (unknownSenders.length > 0) {
|
||||
const batchPromise = loadContactInfoBatch(unknownSenders)
|
||||
unknownSenders.forEach(username => {
|
||||
if (!senderAvatarLoading.has(username)) {
|
||||
senderAvatarLoading.set(username, batchPromise.then(() => senderAvatarCache.get(username) || null))
|
||||
}
|
||||
})
|
||||
batchPromise.finally(() => {
|
||||
unknownSenders.forEach(username => senderAvatarLoading.delete(username))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 加载更多后保持位置:让之前的第一条消息保持在原来的视觉位置
|
||||
if (firstMsgEl && listEl) {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -1101,8 +999,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ... (previous content) ... */}
|
||||
{isLoadingSessions ? (
|
||||
<div className="loading-sessions">
|
||||
{/* ... (skeleton items) ... */}
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar" />
|
||||
@@ -1118,12 +1018,10 @@ function ChatPage(_props: ChatPageProps) {
|
||||
className="session-list"
|
||||
ref={sessionListRef}
|
||||
onScroll={() => {
|
||||
// 标记正在滚动,暂停联系人信息加载
|
||||
isScrollingRef.current = true
|
||||
if (sessionScrollTimeoutRef.current) {
|
||||
clearTimeout(sessionScrollTimeoutRef.current)
|
||||
}
|
||||
// 滚动结束后200ms才认为滚动停止
|
||||
sessionScrollTimeoutRef.current = window.setTimeout(() => {
|
||||
isScrollingRef.current = false
|
||||
sessionScrollTimeoutRef.current = null
|
||||
@@ -1147,6 +1045,8 @@ function ChatPage(_props: ChatPageProps) {
|
||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* 拖动调节条 */}
|
||||
@@ -1157,7 +1057,12 @@ function ChatPage(_props: ChatPageProps) {
|
||||
{currentSession ? (
|
||||
<>
|
||||
<div className="message-header">
|
||||
<SessionAvatar session={currentSession} size={40} />
|
||||
<Avatar
|
||||
src={currentSession.avatarUrl}
|
||||
name={currentSession.displayName || currentSession.username}
|
||||
size={40}
|
||||
className={isGroupChat(currentSession.username) ? 'group session-avatar' : 'session-avatar'}
|
||||
/>
|
||||
<div className="header-info">
|
||||
<h3>{currentSession.displayName || currentSession.username}</h3>
|
||||
{isGroupChat(currentSession.username) && (
|
||||
@@ -1767,13 +1672,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showImagePreview && (
|
||||
{showImagePreview && createPortal(
|
||||
<div className="image-preview-overlay" onClick={() => setShowImagePreview(false)}>
|
||||
<img src={imageLocalPath} alt="图片预览" onClick={(e) => e.stopPropagation()} />
|
||||
<button className="image-preview-close" onClick={() => setShowImagePreview(false)}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
@@ -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="bubble-avatar">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
<span className="avatar-letter">{avatarLetter}</span>
|
||||
)}
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={!isSent ? (isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) : '我'}
|
||||
size={36}
|
||||
className="bubble-avatar"
|
||||
// If it's sent by me (isSent), we might not want 'group' class even if it's a group chat.
|
||||
// But 'group' class mainly handles default avatar icon.
|
||||
// Let's rely on standard Avatar behavior.
|
||||
/>
|
||||
</div>
|
||||
<div className="bubble-body">
|
||||
{/* 群聊中显示发送者名称 */}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check } from 'lucide-react'
|
||||
import { Avatar } from '../components/Avatar'
|
||||
import ReactECharts from 'echarts-for-react'
|
||||
import DateRangePicker from '../components/DateRangePicker'
|
||||
import './GroupAnalyticsPage.scss'
|
||||
@@ -256,11 +257,7 @@ function GroupAnalyticsPage() {
|
||||
</button>
|
||||
<div className="modal-content">
|
||||
<div className="member-avatar large">
|
||||
{selectedMember.avatarUrl ? (
|
||||
<img src={selectedMember.avatarUrl} alt="" />
|
||||
) : (
|
||||
<div className="avatar-placeholder"><User size={48} /></div>
|
||||
)}
|
||||
<Avatar src={selectedMember.avatarUrl} name={selectedMember.displayName} size={96} />
|
||||
</div>
|
||||
<h3 className="member-display-name">{selectedMember.displayName}</h3>
|
||||
<div className="member-details">
|
||||
@@ -334,7 +331,7 @@ function GroupAnalyticsPage() {
|
||||
onClick={() => handleGroupSelect(group)}
|
||||
>
|
||||
<div className="group-avatar">
|
||||
{group.avatarUrl ? <img src={group.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={20} /></div>}
|
||||
<Avatar src={group.avatarUrl} name={group.displayName} size={44} />
|
||||
</div>
|
||||
<div className="group-info">
|
||||
<span className="group-name">{group.displayName}</span>
|
||||
@@ -352,7 +349,7 @@ function GroupAnalyticsPage() {
|
||||
<div className="function-menu">
|
||||
<div className="selected-group-info">
|
||||
<div className="group-avatar large">
|
||||
{selectedGroup?.avatarUrl ? <img src={selectedGroup.avatarUrl} alt="" /> : <div className="avatar-placeholder"><Users size={40} /></div>}
|
||||
<Avatar src={selectedGroup?.avatarUrl} name={selectedGroup?.displayName} size={80} />
|
||||
</div>
|
||||
<h2>{selectedGroup?.displayName}</h2>
|
||||
<p>{selectedGroup?.memberCount} 位成员</p>
|
||||
@@ -424,7 +421,7 @@ function GroupAnalyticsPage() {
|
||||
{members.map(member => (
|
||||
<div key={member.username} className="member-card" onClick={() => handleMemberClick(member)}>
|
||||
<div className="member-avatar">
|
||||
{member.avatarUrl ? <img src={member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={member.avatarUrl} name={member.displayName} size={48} />
|
||||
</div>
|
||||
<span className="member-name">{member.displayName}</span>
|
||||
</div>
|
||||
@@ -437,7 +434,7 @@ function GroupAnalyticsPage() {
|
||||
<div key={item.member.username} className="ranking-item">
|
||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||
<div className="contact-avatar">
|
||||
{item.member.avatarUrl ? <img src={item.member.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
||||
<Avatar src={item.member.avatarUrl} name={item.member.displayName} size={40} />
|
||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||
</div>
|
||||
<div className="contact-info">
|
||||
|
||||
@@ -484,6 +484,7 @@ function SettingsPage() {
|
||||
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
|
||||
</button>
|
||||
{imageKeyStatus && <div className="form-hint status-text">{imageKeyStatus}</div>}
|
||||
{isFetchingImageKey && <div className="form-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
|
||||
@@ -506,7 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
|
||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||
<div className="field-hint">如果获取秘钥失败 请在微信打开后等待10秒后再登录</div>
|
||||
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -533,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
||||
</button>
|
||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface ChatStatistics {
|
||||
totalMessages: number
|
||||
@@ -49,22 +50,29 @@ interface AnalyticsState {
|
||||
clearCache: () => void
|
||||
}
|
||||
|
||||
export const useAnalyticsStore = create<AnalyticsState>((set) => ({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null,
|
||||
export const useAnalyticsStore = create<AnalyticsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null,
|
||||
|
||||
setStatistics: (data) => set({ statistics: data }),
|
||||
setRankings: (data) => set({ rankings: data }),
|
||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||
clearCache: () => set({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null
|
||||
}),
|
||||
}))
|
||||
setStatistics: (data) => set({ statistics: data }),
|
||||
setRankings: (data) => set({ rankings: data }),
|
||||
setTimeDistribution: (data) => set({ timeDistribution: data }),
|
||||
markLoaded: () => set({ isLoaded: true, lastLoadTime: Date.now() }),
|
||||
clearCache: () => set({
|
||||
statistics: null,
|
||||
rankings: [],
|
||||
timeDistribution: null,
|
||||
isLoaded: false,
|
||||
lastLoadTime: null
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'analytics-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
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 }>
|
||||
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
|
||||
close: () => Promise<boolean>
|
||||
|
||||
}
|
||||
key: {
|
||||
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
|
||||
}
|
||||
analytics: {
|
||||
getOverallStatistics: () => Promise<{
|
||||
getOverallStatistics: (force?: boolean) => Promise<{
|
||||
success: boolean
|
||||
data?: {
|
||||
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: {
|
||||
exclude: [
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'node-llama-cpp',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers'
|
||||
]
|
||||
exclude: []
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
@@ -33,16 +26,10 @@ export default defineConfig({
|
||||
vite: {
|
||||
build: {
|
||||
outDir: 'dist-electron',
|
||||
rollupOptions: {
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'better-sqlite3',
|
||||
'koffi',
|
||||
'node-llama-cpp',
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers',
|
||||
'fsevents'
|
||||
]
|
||||
}
|
||||
@@ -57,12 +44,6 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'koffi',
|
||||
'node-llama-cpp',
|
||||
'@lancedb/lancedb',
|
||||
'@lancedb/lancedb-win32-x64-msvc',
|
||||
'onnxruntime-node',
|
||||
'@xenova/transformers',
|
||||
'@huggingface/transformers',
|
||||
'fsevents'
|
||||
],
|
||||
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',
|
||||
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',
|
||||
onstart(options) {
|
||||
|
||||
1
wx_key
Submodule
1
wx_key
Submodule
Submodule wx_key added at 40be59dc47
Reference in New Issue
Block a user