mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
|||||||
dist-electron
|
dist-electron
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
test/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -42,6 +43,10 @@ release
|
|||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Electron dev cache
|
||||||
|
.electron/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 忽略 Visual Studio 临时文件夹
|
# 忽略 Visual Studio 临时文件夹
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService'
|
|||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
@@ -381,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()
|
||||||
@@ -410,6 +413,10 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getContactAvatar(username)
|
return chatService.getContactAvatar(username)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('chat:getCachedMessages', async (_, sessionId: string) => {
|
||||||
|
return chatService.getCachedSessionMessages(sessionId)
|
||||||
|
})
|
||||||
|
|
||||||
ipcMain.handle('chat:getMyAvatarUrl', async () => {
|
ipcMain.handle('chat:getMyAvatarUrl', async () => {
|
||||||
return chatService.getMyAvatarUrl()
|
return chatService.getMyAvatarUrl()
|
||||||
})
|
})
|
||||||
@@ -439,6 +446,9 @@ function registerIpcHandlers() {
|
|||||||
return chatService.getMessageById(sessionId, localId)
|
return chatService.getMessageById(sessionId, localId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 私聊克隆
|
||||||
|
|
||||||
|
|
||||||
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
ipcMain.handle('image:decrypt', async (_, payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => {
|
||||||
return imageDecryptService.decryptImage(payload)
|
return imageDecryptService.decryptImage(payload)
|
||||||
})
|
})
|
||||||
@@ -460,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) => {
|
||||||
@@ -675,9 +685,11 @@ function checkForUpdatesOnStartup() {
|
|||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
configService = new ConfigService()
|
configService = new ConfigService()
|
||||||
const resourcesPath = app.isPackaged
|
const candidateResources = app.isPackaged
|
||||||
? join(process.resourcesPath, 'resources')
|
? join(process.resourcesPath, 'resources')
|
||||||
: join(app.getAppPath(), 'resources')
|
: join(app.getAppPath(), 'resources')
|
||||||
|
const fallbackResources = join(process.cwd(), 'resources')
|
||||||
|
const resourcesPath = existsSync(candidateResources) ? candidateResources : fallbackResources
|
||||||
const userDataPath = app.getPath('userData')
|
const userDataPath = app.getPath('userData')
|
||||||
wcdbService.setPaths(resourcesPath, userDataPath)
|
wcdbService.setPaths(resourcesPath, userDataPath)
|
||||||
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
wcdbService.setLogEnabled(configService.get('logEnabled') === true)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 密钥获取
|
// 密钥获取
|
||||||
@@ -101,12 +102,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username),
|
||||||
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'),
|
||||||
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5),
|
||||||
|
getCachedMessages: (sessionId: string) => ipcRenderer.invoke('chat:getCachedMessages', sessionId),
|
||||||
close: () => ipcRenderer.invoke('chat:close'),
|
close: () => ipcRenderer.invoke('chat:close'),
|
||||||
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId),
|
||||||
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
getImageData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getImageData', sessionId, msgId),
|
||||||
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
getVoiceData: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 图片解密
|
// 图片解密
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) =>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { app } from 'electron'
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { MessageCacheService } from './messageCacheService'
|
||||||
|
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||||
|
|
||||||
type HardlinkState = {
|
type HardlinkState = {
|
||||||
db: Database.Database
|
db: Database.Database
|
||||||
@@ -56,6 +58,7 @@ export interface Message {
|
|||||||
aesKey?: string
|
aesKey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
cdnThumbUrl?: string
|
cdnThumbUrl?: string
|
||||||
|
voiceDurationSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
@@ -74,13 +77,19 @@ class ChatService {
|
|||||||
private connected = false
|
private connected = false
|
||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
||||||
private readonly messageBatchDefault = 50
|
private readonly messageBatchDefault = 50
|
||||||
private avatarCache: Map<string, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
|
private avatarCache: Map<string, ContactCacheEntry>
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
private hardlinkCache = new Map<string, HardlinkState>()
|
||||||
|
private readonly contactCacheService: ContactCacheService
|
||||||
|
private readonly messageCacheService: MessageCacheService
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
|
this.contactCacheService = new ContactCacheService(this.configService.get('cachePath'))
|
||||||
|
const persisted = this.contactCacheService.getAllEntries()
|
||||||
|
this.avatarCache = new Map(Object.entries(persisted))
|
||||||
|
this.messageCacheService = new MessageCacheService(this.configService.get('cachePath'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,7 +240,7 @@ class ChatService {
|
|||||||
let displayName = username
|
let displayName = username
|
||||||
let avatarUrl: string | undefined = undefined
|
let avatarUrl: string | undefined = undefined
|
||||||
const cached = this.avatarCache.get(username)
|
const cached = this.avatarCache.get(username)
|
||||||
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
|
if (cached) {
|
||||||
displayName = cached.displayName || username
|
displayName = cached.displayName || username
|
||||||
avatarUrl = cached.avatarUrl
|
avatarUrl = cached.avatarUrl
|
||||||
}
|
}
|
||||||
@@ -279,6 +288,7 @@ class ChatService {
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const missing: string[] = []
|
const missing: string[] = []
|
||||||
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
|
||||||
|
const updatedEntries: Record<string, ContactCacheEntry> = {}
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
for (const username of usernames) {
|
for (const username of usernames) {
|
||||||
@@ -304,17 +314,20 @@ class ChatService {
|
|||||||
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
|
||||||
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
|
||||||
|
|
||||||
result[username] = { displayName, avatarUrl }
|
const cacheEntry: ContactCacheEntry = {
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
this.avatarCache.set(username, {
|
|
||||||
displayName: displayName || username,
|
displayName: displayName || username,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
updatedAt: now
|
updatedAt: now
|
||||||
})
|
}
|
||||||
|
result[username] = { displayName, avatarUrl }
|
||||||
|
// 更新缓存并记录持久化
|
||||||
|
this.avatarCache.set(username, cacheEntry)
|
||||||
|
updatedEntries[username] = cacheEntry
|
||||||
|
}
|
||||||
|
if (Object.keys(updatedEntries).length > 0) {
|
||||||
|
this.contactCacheService.setEntries(updatedEntries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true, contacts: result }
|
return { success: true, contacts: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 补充联系人信息失败:', e)
|
console.error('ChatService: 补充联系人信息失败:', e)
|
||||||
@@ -456,6 +469,7 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.fetched += rows.length
|
state.fetched += rows.length
|
||||||
|
this.messageCacheService.set(sessionId, normalized)
|
||||||
return { success: true, messages: normalized, hasMore }
|
return { success: true, messages: normalized, hasMore }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取消息失败:', e)
|
console.error('ChatService: 获取消息失败:', e)
|
||||||
@@ -463,6 +477,20 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCachedSessionMessages(sessionId: string): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
|
||||||
|
try {
|
||||||
|
if (!sessionId) return { success: true, messages: [] }
|
||||||
|
const entry = this.messageCacheService.get(sessionId)
|
||||||
|
if (!entry || !Array.isArray(entry.messages)) {
|
||||||
|
return { success: true, messages: [] }
|
||||||
|
}
|
||||||
|
return { success: true, messages: entry.messages.slice() }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ChatService: 获取缓存消息失败:', error)
|
||||||
|
return { success: false, error: String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL
|
* 尝试从 emoticon.db / emotion.db 恢复表情包 CDN URL
|
||||||
*/
|
*/
|
||||||
@@ -639,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',
|
||||||
@@ -648,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)
|
||||||
@@ -668,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}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1453,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
|
||||||
}
|
}
|
||||||
@@ -1463,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))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是字符串
|
// 如果是字符串
|
||||||
@@ -1479,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,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 { }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1501,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 {
|
||||||
@@ -1528,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 || ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,7 +1658,13 @@ class ChatService {
|
|||||||
const avatarResult = await wcdbService.getAvatarUrls([username])
|
const avatarResult = await wcdbService.getAvatarUrls([username])
|
||||||
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
|
const avatarUrl = avatarResult.success && avatarResult.map ? avatarResult.map[username] : undefined
|
||||||
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
|
const displayName = contact?.remark || contact?.nickName || contact?.alias || cached?.displayName || username
|
||||||
this.avatarCache.set(username, { avatarUrl, displayName, updatedAt: Date.now() })
|
const cacheEntry: ContactCacheEntry = {
|
||||||
|
avatarUrl,
|
||||||
|
displayName,
|
||||||
|
updatedAt: Date.now()
|
||||||
|
}
|
||||||
|
this.avatarCache.set(username, cacheEntry)
|
||||||
|
this.contactCacheService.setEntries({ [username]: cacheEntry })
|
||||||
return { avatarUrl, displayName }
|
return { avatarUrl, displayName }
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -1633,11 +1687,24 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cleanedWxid = this.cleanAccountDirName(myWxid)
|
const cleanedWxid = this.cleanAccountDirName(myWxid)
|
||||||
const result = await wcdbService.getAvatarUrls([myWxid, cleanedWxid])
|
// 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中
|
||||||
|
const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self']))
|
||||||
|
|
||||||
|
console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`)
|
||||||
|
const result = await wcdbService.getAvatarUrls(fetchList)
|
||||||
|
|
||||||
if (result.success && result.map) {
|
if (result.success && result.map) {
|
||||||
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid]
|
// 按优先级尝试匹配
|
||||||
|
const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
|
||||||
|
if (avatarUrl) {
|
||||||
|
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
|
||||||
return { success: true, avatarUrl }
|
return { success: true, avatarUrl }
|
||||||
}
|
}
|
||||||
|
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
|
||||||
|
return { success: true, avatarUrl: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
|
||||||
return { success: true, avatarUrl: undefined }
|
return { success: true, avatarUrl: undefined }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('ChatService: 获取当前用户头像失败:', e)
|
console.error('ChatService: 获取当前用户头像失败:', e)
|
||||||
@@ -2462,12 +2529,12 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const aesData = payload.subarray(0, alignedAesSize)
|
const aesData = payload.subarray(0, alignedAesSize)
|
||||||
let unpadded = Buffer.alloc(0)
|
let unpadded: Buffer = Buffer.alloc(0)
|
||||||
if (aesData.length > 0) {
|
if (aesData.length > 0) {
|
||||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, Buffer.alloc(0))
|
||||||
decipher.setAutoPadding(false)
|
decipher.setAutoPadding(false)
|
||||||
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
||||||
unpadded = this.strictRemovePadding(decrypted)
|
unpadded = this.strictRemovePadding(decrypted) as Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = payload.subarray(alignedAesSize)
|
const remaining = payload.subarray(alignedAesSize)
|
||||||
@@ -2475,21 +2542,21 @@ class ChatService {
|
|||||||
throw new Error('文件格式异常:XOR 数据长度不合法')
|
throw new Error('文件格式异常:XOR 数据长度不合法')
|
||||||
}
|
}
|
||||||
|
|
||||||
let rawData = Buffer.alloc(0)
|
let rawData: Buffer = Buffer.alloc(0)
|
||||||
let xoredData = Buffer.alloc(0)
|
let xoredData: Buffer = Buffer.alloc(0)
|
||||||
if (xorSize > 0) {
|
if (xorSize > 0) {
|
||||||
const rawLength = remaining.length - xorSize
|
const rawLength = remaining.length - xorSize
|
||||||
if (rawLength < 0) {
|
if (rawLength < 0) {
|
||||||
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
||||||
}
|
}
|
||||||
rawData = remaining.subarray(0, rawLength)
|
rawData = remaining.subarray(0, rawLength) as Buffer
|
||||||
const xorData = remaining.subarray(rawLength)
|
const xorData = remaining.subarray(rawLength)
|
||||||
xoredData = Buffer.alloc(xorData.length)
|
xoredData = Buffer.alloc(xorData.length)
|
||||||
for (let i = 0; i < xorData.length; i++) {
|
for (let i = 0; i < xorData.length; i++) {
|
||||||
xoredData[i] = xorData[i] ^ xorKey
|
xoredData[i] = xorData[i] ^ xorKey
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rawData = remaining
|
rawData = remaining as Buffer
|
||||||
xoredData = Buffer.alloc(0)
|
xoredData = Buffer.alloc(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ConfigSchema {
|
|||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
|
llmModelPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -40,7 +41,8 @@ export class ConfigService {
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false
|
logEnabled: false,
|
||||||
|
llmModelPath: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
75
electron/services/contactCacheService.ts
Normal file
75
electron/services/contactCacheService.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export interface ContactCacheEntry {
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContactCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private cache: Record<string, ContactCacheEntry> = {}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: join(app.getPath('userData'), 'WeFlowCache')
|
||||||
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.loadCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir() {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCache() {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.cache = parsed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 载入缓存失败', error)
|
||||||
|
this.cache = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(username: string): ContactCacheEntry | undefined {
|
||||||
|
return this.cache[username]
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEntries(): Record<string, ContactCacheEntry> {
|
||||||
|
return { ...this.cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntries(entries: Record<string, ContactCacheEntry>): void {
|
||||||
|
if (Object.keys(entries).length === 0) return
|
||||||
|
let changed = false
|
||||||
|
for (const [username, entry] of Object.entries(entries)) {
|
||||||
|
const existing = this.cache[username]
|
||||||
|
if (!existing || entry.updatedAt >= existing.updatedAt) {
|
||||||
|
this.cache[username] = entry
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ interface ChatLabMeta {
|
|||||||
platform: string
|
platform: string
|
||||||
type: 'group' | 'private'
|
type: 'group' | 'private'
|
||||||
groupId?: string
|
groupId?: string
|
||||||
|
groupAvatar?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatLabMember {
|
interface ChatLabMember {
|
||||||
@@ -425,6 +426,81 @@ class ExportService {
|
|||||||
return { rows, memberSet, firstTime, lastTime }
|
return { rows, memberSet, firstTime, lastTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 补齐群成员,避免只导出发言者导致头像缺失
|
||||||
|
private async mergeGroupMembers(
|
||||||
|
chatroomId: string,
|
||||||
|
memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>,
|
||||||
|
includeAvatars: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const result = await wcdbService.getGroupMembers(chatroomId)
|
||||||
|
if (!result.success || !result.members || result.members.length === 0) return
|
||||||
|
|
||||||
|
const rawMembers = result.members as Array<{
|
||||||
|
username?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
nickname?: string
|
||||||
|
displayName?: string
|
||||||
|
remark?: string
|
||||||
|
originalName?: string
|
||||||
|
}>
|
||||||
|
const usernames = rawMembers
|
||||||
|
.map((member) => member.username)
|
||||||
|
.filter((username): username is string => Boolean(username))
|
||||||
|
if (usernames.length === 0) return
|
||||||
|
|
||||||
|
const lookupUsernames = new Set<string>()
|
||||||
|
for (const username of usernames) {
|
||||||
|
lookupUsernames.add(username)
|
||||||
|
const cleaned = this.cleanAccountDirName(username)
|
||||||
|
if (cleaned && cleaned !== username) {
|
||||||
|
lookupUsernames.add(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [displayNames, avatarUrls] = await Promise.all([
|
||||||
|
wcdbService.getDisplayNames(Array.from(lookupUsernames)),
|
||||||
|
includeAvatars ? wcdbService.getAvatarUrls(Array.from(lookupUsernames)) : Promise.resolve({ success: true, map: {} as Record<string, string> })
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const member of rawMembers) {
|
||||||
|
const username = member.username
|
||||||
|
if (!username) continue
|
||||||
|
|
||||||
|
const cleaned = this.cleanAccountDirName(username)
|
||||||
|
const displayName = displayNames.success && displayNames.map
|
||||||
|
? (displayNames.map[username] || (cleaned ? displayNames.map[cleaned] : undefined) || username)
|
||||||
|
: username
|
||||||
|
const groupNickname = member.nickname || member.displayName || member.remark || member.originalName
|
||||||
|
const avatarUrl = includeAvatars && avatarUrls.success && avatarUrls.map
|
||||||
|
? (avatarUrls.map[username] || (cleaned ? avatarUrls.map[cleaned] : undefined) || member.avatarUrl)
|
||||||
|
: member.avatarUrl
|
||||||
|
|
||||||
|
const existing = memberSet.get(username)
|
||||||
|
if (existing) {
|
||||||
|
if (displayName && existing.member.accountName === existing.member.platformId && displayName !== existing.member.platformId) {
|
||||||
|
existing.member.accountName = displayName
|
||||||
|
}
|
||||||
|
if (groupNickname && !existing.member.groupNickname) {
|
||||||
|
existing.member.groupNickname = groupNickname
|
||||||
|
}
|
||||||
|
if (!existing.avatarUrl && avatarUrl) {
|
||||||
|
existing.avatarUrl = avatarUrl
|
||||||
|
}
|
||||||
|
memberSet.set(username, existing)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatlabMember: ChatLabMember = {
|
||||||
|
platformId: username,
|
||||||
|
accountName: displayName
|
||||||
|
}
|
||||||
|
if (groupNickname) {
|
||||||
|
chatlabMember.groupNickname = groupNickname
|
||||||
|
}
|
||||||
|
memberSet.set(username, { member: chatlabMember, avatarUrl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
|
private resolveAvatarFile(avatarUrl?: string): { data?: Buffer; sourcePath?: string; sourceUrl?: string; ext: string; mime?: string } | null {
|
||||||
if (!avatarUrl) return null
|
if (!avatarUrl) return null
|
||||||
if (avatarUrl.startsWith('data:')) {
|
if (avatarUrl.startsWith('data:')) {
|
||||||
@@ -514,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 {
|
||||||
@@ -525,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':
|
||||||
@@ -567,6 +680,9 @@ class ExportService {
|
|||||||
|
|
||||||
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
|
||||||
const allMessages = collected.rows
|
const allMessages = collected.rows
|
||||||
|
if (isGroup) {
|
||||||
|
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
|
||||||
|
}
|
||||||
|
|
||||||
allMessages.sort((a, b) => a.createTime - b.createTime)
|
allMessages.sort((a, b) => a.createTime - b.createTime)
|
||||||
|
|
||||||
@@ -580,11 +696,13 @@ 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,
|
||||||
accountName: memberInfo.accountName,
|
accountName: memberInfo.accountName,
|
||||||
|
groupNickname: memberInfo.groupNickname,
|
||||||
timestamp: msg.createTime,
|
timestamp: msg.createTime,
|
||||||
type: this.convertMessageType(msg.localType, msg.content),
|
type: this.convertMessageType(msg.localType, msg.content),
|
||||||
content: this.parseMessageContent(msg.content, msg.localType)
|
content: this.parseMessageContent(msg.content, msg.localType)
|
||||||
@@ -603,6 +721,7 @@ class ExportService {
|
|||||||
)
|
)
|
||||||
: new Map<string, string>()
|
: new Map<string, string>()
|
||||||
|
|
||||||
|
const sessionAvatar = avatarMap.get(sessionId)
|
||||||
const members = Array.from(collected.memberSet.values()).map((info) => {
|
const members = Array.from(collected.memberSet.values()).map((info) => {
|
||||||
const avatar = avatarMap.get(info.member.platformId)
|
const avatar = avatarMap.get(info.member.platformId)
|
||||||
return avatar ? { ...info.member, avatar } : info.member
|
return avatar ? { ...info.member, avatar } : info.member
|
||||||
@@ -618,7 +737,8 @@ class ExportService {
|
|||||||
name: sessionInfo.displayName,
|
name: sessionInfo.displayName,
|
||||||
platform: 'wechat',
|
platform: 'wechat',
|
||||||
type: isGroup ? 'group' : 'private',
|
type: isGroup ? 'group' : 'private',
|
||||||
...(isGroup && { groupId: sessionId })
|
...(isGroup && { groupId: sessionId }),
|
||||||
|
...(sessionAvatar && { groupAvatar: sessionAvatar })
|
||||||
},
|
},
|
||||||
members,
|
members,
|
||||||
messages: chatLabMessages
|
messages: chatLabMessages
|
||||||
@@ -729,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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,7 +281,7 @@ export class ImageDecryptService {
|
|||||||
if (this.isAccountDir(entryPath)) return entryPath
|
if (this.isAccountDir(entryPath)) return entryPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -474,7 +522,7 @@ export class ImageDecryptService {
|
|||||||
if (!hasUpdate) return
|
if (!hasUpdate) return
|
||||||
this.updateFlags.set(cacheKey, true)
|
this.updateFlags.set(cacheKey, true)
|
||||||
this.emitImageUpdate(payload, cacheKey)
|
this.emitImageUpdate(payload, cacheKey)
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
private looksLikeMd5(value: string): boolean {
|
private looksLikeMd5(value: string): boolean {
|
||||||
@@ -863,6 +911,8 @@ export class ImageDecryptService {
|
|||||||
}
|
}
|
||||||
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
const thumbPath = join(dirPath, `${normalizedKey}_thumb${ext}`)
|
||||||
if (existsSync(thumbPath)) return thumbPath
|
if (existsSync(thumbPath)) return thumbPath
|
||||||
|
|
||||||
|
// 允许返回 _hd 格式(因为它有 _hd 变体后缀)
|
||||||
if (!preferHd) {
|
if (!preferHd) {
|
||||||
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
const hdPath = join(dirPath, `${normalizedKey}_hd${ext}`)
|
||||||
if (existsSync(hdPath)) return hdPath
|
if (existsSync(hdPath)) return hdPath
|
||||||
@@ -960,8 +1010,9 @@ export class ImageDecryptService {
|
|||||||
const lower = entry.toLowerCase()
|
const lower = entry.toLowerCase()
|
||||||
if (!lower.endsWith('.dat')) continue
|
if (!lower.endsWith('.dat')) continue
|
||||||
if (this.isThumbnailDat(lower)) continue
|
if (this.isThumbnailDat(lower)) continue
|
||||||
if (!this.hasXVariant(lower.slice(0, -4))) continue
|
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
|
// 只排除没有 _x 变体后缀的文件(允许 _hd、_h 等所有带变体的)
|
||||||
|
if (!this.hasXVariant(baseLower)) continue
|
||||||
if (this.normalizeDatBase(baseLower) !== target) continue
|
if (this.normalizeDatBase(baseLower) !== target) continue
|
||||||
return join(dirPath, entry)
|
return join(dirPath, entry)
|
||||||
}
|
}
|
||||||
@@ -973,6 +1024,7 @@ export class ImageDecryptService {
|
|||||||
if (!lower.endsWith('.dat')) return false
|
if (!lower.endsWith('.dat')) return false
|
||||||
if (this.isThumbnailDat(lower)) return false
|
if (this.isThumbnailDat(lower)) return false
|
||||||
const baseLower = lower.slice(0, -4)
|
const baseLower = lower.slice(0, -4)
|
||||||
|
// 只检查是否有 _x 变体后缀(允许 _hd、_h 等所有带变体的)
|
||||||
return this.hasXVariant(baseLower)
|
return this.hasXVariant(baseLower)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1332,10 +1384,10 @@ export class ImageDecryptService {
|
|||||||
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
keyCount.set(key, (keyCount.get(key) || 0) + 1)
|
||||||
filesChecked++
|
filesChecked++
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
scanDir(dirPath)
|
scanDir(dirPath)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
68
electron/services/messageCacheService.ts
Normal file
68
electron/services/messageCacheService.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export interface SessionMessageCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||||
|
private readonly sessionLimit = 150
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: join(app.getPath('userData'), 'WeFlowCache')
|
||||||
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.loadCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir() {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCache() {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.cache = parsed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 载入缓存失败', error)
|
||||||
|
this.cache = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||||
|
return this.cache[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
set(sessionId: string, messages: any[]): void {
|
||||||
|
if (!sessionId) return
|
||||||
|
const trimmed = messages.length > this.sessionLimit
|
||||||
|
? messages.slice(-this.sessionLimit)
|
||||||
|
: messages.slice()
|
||||||
|
this.cache[sessionId] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: trimmed
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"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": "echo 'No native modules to rebuild'",
|
||||||
|
"rebuild": "echo 'No native modules to rebuild'",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build && electron-builder",
|
"build": "tsc && vite build && electron-builder",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"electron:dev": "vite --mode electron",
|
"electron:dev": "vite --mode electron",
|
||||||
"electron:build": "npm run build",
|
"electron:build": "npm run build"
|
||||||
"postinstall": "electron-rebuild"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
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'
|
||||||
@@ -14,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
|||||||
import DataManagementPage from './pages/DataManagementPage'
|
import DataManagementPage from './pages/DataManagementPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
import * as configService from './services/config'
|
import * as configService from './services/config'
|
||||||
@@ -307,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,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
@@ -34,6 +34,8 @@ function Sidebar() {
|
|||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 私聊分析 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/analytics"
|
to="/analytics"
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -278,6 +127,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
const sessionListRef = useRef<HTMLDivElement>(null)
|
const sessionListRef = useRef<HTMLDivElement>(null)
|
||||||
const [currentOffset, setCurrentOffset] = useState(0)
|
const [currentOffset, setCurrentOffset] = useState(0)
|
||||||
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
const [myAvatarUrl, setMyAvatarUrl] = useState<string | undefined>(undefined)
|
||||||
|
const [myWxid, setMyWxid] = useState<string | undefined>(undefined)
|
||||||
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(260)
|
const [sidebarWidth, setSidebarWidth] = useState(260)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -354,6 +204,9 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
setConnected(true)
|
setConnected(true)
|
||||||
await loadSessions()
|
await loadSessions()
|
||||||
await loadMyAvatar()
|
await loadMyAvatar()
|
||||||
|
// 获取 myWxid 用于匹配个人头像
|
||||||
|
const wxid = await window.electronAPI.config.get('myWxid')
|
||||||
|
if (wxid) setMyWxid(wxid as string)
|
||||||
} else {
|
} else {
|
||||||
setConnectionError(result.error || '连接失败')
|
setConnectionError(result.error || '连接失败')
|
||||||
}
|
}
|
||||||
@@ -380,10 +233,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 +271,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 +280,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 +434,21 @@ 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)
|
||||||
|
|
||||||
|
// 如果是自己的信息且当前个人头像为空,同步更新
|
||||||
|
if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) {
|
||||||
|
console.log('[ChatPage] 从联系人同步获取到个人头像')
|
||||||
|
setMyAvatarUrl(contact.avatarUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【核心优化】同步更新全局发送者头像缓存,供 MessageBubble 使用
|
||||||
|
senderAvatarCache.set(username, {
|
||||||
|
avatarUrl: contact.avatarUrl,
|
||||||
|
displayName: contact.displayName
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// 触发批量更新
|
// 触发批量更新
|
||||||
flushContactUpdates()
|
flushContactUpdates()
|
||||||
@@ -660,6 +522,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 +555,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(() => {
|
||||||
@@ -906,7 +814,7 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (payloads.length > 0) {
|
if (payloads.length > 0) {
|
||||||
window.electronAPI.image.preload(payloads).catch(() => {})
|
window.electronAPI.image.preload(payloads).catch(() => { })
|
||||||
}
|
}
|
||||||
}, [currentSessionId, messages])
|
}, [currentSessionId, messages])
|
||||||
|
|
||||||
@@ -1101,8 +1009,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 +1028,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 +1055,8 @@ function ChatPage(_props: ChatPageProps) {
|
|||||||
<p className="hint">请先在数据管理页面解密数据库</p>
|
<p className="hint">请先在数据管理页面解密数据库</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 拖动调节条 */}
|
{/* 拖动调节条 */}
|
||||||
@@ -1157,7 +1067,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) && (
|
||||||
@@ -1434,7 +1349,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
|
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
|
||||||
return 'image/webp'
|
return 'image/webp'
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
return 'image/jpeg'
|
return 'image/jpeg'
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -1473,9 +1388,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊中获取发送者信息
|
// 群聊中获取发送者信息 (如果自己发的没头像,也尝试拉取)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isGroupChat && !isSent && message.senderUsername) {
|
if (message.senderUsername && (isGroupChat || (isSent && !myAvatarUrl))) {
|
||||||
const sender = message.senderUsername
|
const sender = message.senderUsername
|
||||||
const cached = senderAvatarCache.get(sender)
|
const cached = senderAvatarCache.get(sender)
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@@ -1501,11 +1416,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
setSenderAvatarUrl(result.avatarUrl)
|
setSenderAvatarUrl(result.avatarUrl)
|
||||||
setSenderName(result.displayName)
|
setSenderName(result.displayName)
|
||||||
}
|
}
|
||||||
}).catch(() => {}).finally(() => {
|
}).catch(() => { }).finally(() => {
|
||||||
senderAvatarLoading.delete(sender)
|
senderAvatarLoading.delete(sender)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [isGroupChat, isSent, message.senderUsername])
|
}, [isGroupChat, isSent, message.senderUsername, myAvatarUrl])
|
||||||
|
|
||||||
// 自动下载表情包
|
// 自动下载表情包
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1597,7 +1512,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
}
|
}
|
||||||
setImageHasUpdate(Boolean(result.hasUpdate))
|
setImageHasUpdate(Boolean(result.hasUpdate))
|
||||||
}
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => { })
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
@@ -1672,11 +1587,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
const bubbleClass = isSent ? 'sent' : 'received'
|
const bubbleClass = isSent ? 'sent' : 'received'
|
||||||
|
|
||||||
// 头像逻辑:
|
// 头像逻辑:
|
||||||
// - 自己发的:使用 myAvatarUrl
|
// - 自己发的:优先使用 myAvatarUrl,缺失则用 senderAvatarUrl (补救)
|
||||||
// - 群聊中对方发的:使用发送者头像
|
// - 群聊中对方发的:使用发送者头像
|
||||||
// - 私聊中对方发的:使用会话头像
|
// - 私聊中对方发的:使用会话头像
|
||||||
const avatarUrl = isSent
|
const avatarUrl = isSent
|
||||||
? myAvatarUrl
|
? (myAvatarUrl || senderAvatarUrl)
|
||||||
: (isGroupChat ? senderAvatarUrl : session.avatarUrl)
|
: (isGroupChat ? senderAvatarUrl : session.avatarUrl)
|
||||||
const avatarLetter = isSent
|
const avatarLetter = isSent
|
||||||
? '我'
|
? '我'
|
||||||
@@ -1685,6 +1600,12 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
// 是否有引用消息
|
// 是否有引用消息
|
||||||
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
const hasQuote = message.quotedContent && message.quotedContent.length > 0
|
||||||
|
|
||||||
|
// 去除企业微信 ID 前缀
|
||||||
|
const cleanMessageContent = (content: string) => {
|
||||||
|
if (!content) return ''
|
||||||
|
return content.replace(/^[a-zA-Z0-9]+@openim:\n?/, '')
|
||||||
|
}
|
||||||
|
|
||||||
// 解析混合文本和表情
|
// 解析混合文本和表情
|
||||||
const renderTextWithEmoji = (text: string) => {
|
const renderTextWithEmoji = (text: string) => {
|
||||||
if (!text) return text
|
if (!text) return text
|
||||||
@@ -1761,13 +1682,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
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -1895,14 +1817,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
<div className="bubble-content">
|
<div className="bubble-content">
|
||||||
<div className="quoted-message">
|
<div className="quoted-message">
|
||||||
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
{message.quotedSender && <span className="quoted-sender">{message.quotedSender}</span>}
|
||||||
<span className="quoted-text">{renderTextWithEmoji(message.quotedContent || '')}</span>
|
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(message.quotedContent || ''))}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text">{renderTextWithEmoji(message.parsedContent)}</div>
|
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 普通消息
|
// 普通消息
|
||||||
return <div className="bubble-content">{renderTextWithEmoji(message.parsedContent)}</div>
|
return <div className="bubble-content">{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1914,11 +1836,15 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat }:
|
|||||||
)}
|
)}
|
||||||
<div className={`message-bubble ${bubbleClass} ${isEmoji && message.emojiCdnUrl && !emojiError ? 'emoji' : ''} ${isImage ? 'image' : ''} ${isVoice ? 'voice' : ''}`}>
|
<div className={`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,6 +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">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -532,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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
|
|||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
LOG_ENABLED: 'logEnabled',
|
LOG_ENABLED: 'logEnabled',
|
||||||
ONBOARDING_DONE: 'onboardingDone',
|
ONBOARDING_DONE: 'onboardingDone',
|
||||||
|
LLM_MODEL_PATH: 'llmModelPath',
|
||||||
IMAGE_XOR_KEY: 'imageXorKey',
|
IMAGE_XOR_KEY: 'imageXorKey',
|
||||||
IMAGE_AES_KEY: 'imageAesKey'
|
IMAGE_AES_KEY: 'imageAesKey'
|
||||||
} as const
|
} as const
|
||||||
@@ -132,6 +133,17 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 LLM 模型路径
|
||||||
|
export async function getLlmModelPath(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 LLM 模型路径
|
||||||
|
export async function setLlmModelPath(path: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
||||||
|
}
|
||||||
|
|
||||||
// 清除所有配置
|
// 清除所有配置
|
||||||
export async function clearConfig(): Promise<void> {
|
export async function clearConfig(): Promise<void> {
|
||||||
await config.clear()
|
await config.clear()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
4
src/types/electron.d.ts
vendored
4
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[] }>
|
||||||
@@ -95,6 +96,7 @@ export interface ElectronAPI {
|
|||||||
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
getVoiceData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
image: {
|
image: {
|
||||||
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
|
||||||
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }>
|
||||||
@@ -103,7 +105,7 @@ export interface ElectronAPI {
|
|||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
|
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()
|
||||||
@@ -10,6 +10,14 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
commonjsOptions: {
|
||||||
|
ignoreDynamicRequires: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: []
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
electron([
|
electron([
|
||||||
@@ -19,7 +27,11 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist-electron',
|
outDir: 'dist-electron',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['better-sqlite3', 'koffi']
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +42,10 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist-electron',
|
outDir: 'dist-electron',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['koffi'],
|
external: [
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
],
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'annualReportWorker.js',
|
entryFileNames: 'annualReportWorker.js',
|
||||||
inlineDynamicImports: true
|
inlineDynamicImports: true
|
||||||
@@ -53,6 +68,25 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/wcdbWorker.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'wcdbWorker.js',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
entry: 'electron/preload.ts',
|
entry: 'electron/preload.ts',
|
||||||
onstart(options) {
|
onstart(options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user