Compare commits

..

33 Commits

Author SHA1 Message Date
Forrest
b96a47fe29 Merge pull request #36 from hicccc77/dev
Dev
2026-01-15 00:34:51 +08:00
cc
b7eb19aad6 fix: 进一步修复头像无法加载的问题;修复了构建脚本错误的配置 2026-01-14 22:49:19 +08:00
cc
2e41a03c96 feat: 所有数据解析完全后台进行以解决页面未响应的问题;优化了头像渲染逻辑以提升渲染速度
fix: 修复了虚拟机上无法索引到wxkey的问题;修复图片密钥扫描的问题;修复年度报告错误;修复了年度报告和数据分析中的发送者错误问题;修复了部分页面偶发的未渲染名称问题;修复了头像偶发渲染失败的问题;修复了部分图片无法解密的问题
2026-01-14 22:43:42 +08:00
cc
3151f79ee7 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-01-14 19:44:34 +08:00
cc
e7c93ea2f7 fix: 修复一些代码报错; 移除了好友复刻的功能 2026-01-14 19:44:09 +08:00
xuncha
f09ab1bbcc fix: 修复了群头像导出丢失的问题 2026-01-14 13:31:51 +08:00
xuncha
e6a0726b8d Merge work into dev and resolve ChatPage.tsx by accepting work changes 2026-01-13 18:01:16 +08:00
xuncha
cada002587 fix: 修复了企业微信会显示出id的问题 增加了新手教程提示 2026-01-13 17:50:56 +08:00
Forrest
38e87b8cbf feat: 添加LLM模型加载和释放功能,优化LLM推理流程 2026-01-13 00:19:59 +08:00
cc
bd94ba7b1a 测试版本,添加了模拟好友并优化了本地缓存 2026-01-12 23:42:09 +08:00
cc
756ee03aa0 Merge pull request #24 from hicccc77/dev
Merge pull request #23 from hicccc77/main
2026-01-12 22:27:42 +08:00
cc
76aa875085 Merge pull request #23 from hicccc77/main
同步
2026-01-12 22:26:36 +08:00
cc
16fa8510e6 chore: 更新文档 2026-01-12 22:25:21 +08:00
cc
b587e6bd6f Merge pull request #19 from XiiTang/main
fix: 更新getXorKey方法以改进密钥提取逻辑并添加PNG支持
2026-01-12 22:09:19 +08:00
XiiTang
13cc3751b5 feat: 添加通话消息解析功能 2026-01-12 14:38:34 +08:00
XiiTang
ba65c5f3ad feat: 添加年度报告的自定义导出选项 2026-01-12 11:29:06 +08:00
XiiTang
cfd7635323 fix: 更新getXorKey方法以改进密钥提取逻辑并添加PNG支持 2026-01-12 11:04:20 +08:00
xuncha
895249940c Update Telegram badge in README.md 2026-01-12 04:21:55 +08:00
xuncha
6b85d8a5f1 Add Telegram badge to README 2026-01-12 04:19:24 +08:00
Forrest
5c1773efac fix: 更新致谢部分 2026-01-12 00:37:51 +08:00
Forrest
fa783159ff fix: 更新release.yml,移除ignore_labels的默认值以支持自定义标签 2026-01-12 00:24:52 +08:00
Forrest
e85254bf98 feat: 添加联系人信息异步加载功能,优化会话列表展示 2026-01-12 00:12:42 +08:00
cc
e5f57c7359 feat: 优化会话加载速度;优化动画表现;支持中文数据库路径 2026-01-11 23:32:05 +08:00
xuncha
4cbce8c38f Merge pull request #16 from xunchahaha/main
fix:修复了语音解密
2026-01-11 22:58:38 +08:00
xuncha
d111513346 fix:修复了语音解密 2026-01-11 22:57:38 +08:00
xuncha
e2d34fc530 Merge pull request #9 from xunchahaha/main
fix: 导出时无需再去设置里面选择位置
2026-01-10 23:56:03 +08:00
xuncha
a1d11e4132 fix: 导出时无需再去设置里面选择位置 2026-01-10 23:55:21 +08:00
xuncha
ac95c99541 Merge pull request #8 from xunchahaha/main
fix: 修复了导出无法选择时间的问题
2026-01-10 23:44:44 +08:00
xuncha
654eb40740 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-10 23:41:20 +08:00
xuncha
bd3e9a63b7 fix: 统一ui风格 修复导出当天的记录为空的问题 2026-01-10 23:41:15 +08:00
xuncha
bc9ef140f5 fix: 简单修复了导出无法选择时间的问题 2026-01-10 23:26:52 +08:00
cc
f864189407 fix: 优化action打包效果 2026-01-10 14:11:01 +08:00
cc
f321c465d5 feat: 减少安装包体积 2026-01-10 14:10:34 +08:00
41 changed files with 4461 additions and 1585 deletions

View File

@@ -39,12 +39,24 @@ jobs:
outputFile: "release-notes.md" outputFile: "release-notes.md"
configurationJson: | configurationJson: |
{ {
"template": "# v${{ github.ref_name }} 版本发布\n\n{{CHANGELOG}}\n\n---\n> 此更新由系统自动构建",
"categories": [ "categories": [
{ "title": "## 🚀 Features", "labels": ["feat", "feature"] }, {
{ "title": "## 🐛 Fixes", "labels": ["fix", "bug"] }, "title": "## 新功能",
{ "title": "## 🧰 Maintenance", "labels": ["chore", "refactor", "docs", "perf"] } "filter": { "pattern": "^feat:.*", "flags": "i" }
},
{
"title": "## 修复",
"filter": { "pattern": "^fix:.*", "flags": "i" }
},
{
"title": "## 性能与维护",
"filter": { "pattern": "^(chore|docs|perf|refactor):.*", "flags": "i" }
}
], ],
"template": "# Release Notes\n\n{{CHANGELOG}}" "ignore_labels": [],
"commitMode": true,
"empty_summary": "## 更新详情\n- 常规代码优化与维护"
} }
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -52,5 +64,5 @@ jobs:
- name: Package and Publish - name: Package and Publish
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
run: npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md" npx electron-builder --publish always "-c.releaseInfo.releaseNotesFile=release-notes.md"

7
.gitignore vendored
View File

@@ -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 临时文件夹
@@ -50,4 +55,4 @@ Thumbs.db
*.ipch *.ipch
*.aps *.aps
wcdb/ wcdb/

View File

@@ -20,8 +20,8 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
<a href="https://github.com/hicccc77/WeFlow/issues"> <a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues"> <img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
</a> </a>
<a href="https://github.com/hicccc77/WeFlow/blob/main/LICENSE"> <a href="https://t.me/+hn3QzNc4DbA0MzNl">
<img src="https://img.shields.io/github/license/hicccc77/WeFlow?style=flat-square" alt="License"> <img src="https://img.shields.io/badge/Telegram%20交流群-点击加入-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">
</a> </a>
</p> </p>
@@ -92,7 +92,7 @@ WeFlow/
## 致谢 ## 致谢
- [miyu](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架 - [密语 CipherTalk](https://github.com/ILoveBingLu/miyu) 为本项目提供了基础框架
## Star History ## Star History

View File

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

View File

@@ -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()
@@ -390,6 +393,10 @@ function registerIpcHandlers() {
return chatService.getSessions() return chatService.getSessions()
}) })
ipcMain.handle('chat:enrichSessionsContactInfo', async (_, usernames: string[]) => {
return chatService.enrichSessionsContactInfo(usernames)
})
ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => {
return chatService.getMessages(sessionId, offset, limit) return chatService.getMessages(sessionId, offset, limit)
}) })
@@ -406,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()
}) })
@@ -435,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)
}) })
@@ -456,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) => {
@@ -671,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)

View File

@@ -69,7 +69,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid),
open: (dbPath: string, hexKey: string, wxid: string) => open: (dbPath: string, hexKey: string, wxid: string) =>
ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid),
close: () => ipcRenderer.invoke('wcdb:close') close: () => ipcRenderer.invoke('wcdb:close'),
}, },
// 密钥获取 // 密钥获取
@@ -91,6 +92,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
chat: { chat: {
connect: () => ipcRenderer.invoke('chat:connect'), connect: () => ipcRenderer.invoke('chat:connect'),
getSessions: () => ipcRenderer.invoke('chat:getSessions'), getSessions: () => ipcRenderer.invoke('chat:getSessions'),
enrichSessionsContactInfo: (usernames: string[]) =>
ipcRenderer.invoke('chat:enrichSessionsContactInfo', usernames),
getMessages: (sessionId: string, offset?: number, limit?: number) => getMessages: (sessionId: string, offset?: number, limit?: number) =>
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
@@ -99,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 }) =>

View File

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

View File

@@ -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'))
} }
/** /**
@@ -166,7 +175,7 @@ class ChatService {
} }
/** /**
* 获取会话列表 * 获取会话列表(优化:先返回基础数据,不等待联系人信息加载)
*/ */
async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> { async getSessions(): Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> {
try { try {
@@ -189,8 +198,10 @@ class ChatService {
return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` } return { success: false, error: `会话表异常: ${detail}${tableInfo}${tables}${columns}` }
} }
// 转换为 ChatSession // 转换为 ChatSession(先加载缓存,但不等待数据库查询)
const sessions: ChatSession[] = [] const sessions: ChatSession[] = []
const now = Date.now()
for (const row of rows) { for (const row of rows) {
const username = const username =
row.username || row.username ||
@@ -225,6 +236,15 @@ class ChatService {
const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '') const summary = this.cleanString(row.summary || row.digest || row.last_msg || row.lastMsg || '')
const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10) const lastMsgType = parseInt(row.last_msg_type || row.lastMsgType || '0', 10)
// 先尝试从缓存获取联系人信息(快速路径)
let displayName = username
let avatarUrl: string | undefined = undefined
const cached = this.avatarCache.get(username)
if (cached) {
displayName = cached.displayName || username
avatarUrl = cached.avatarUrl
}
sessions.push({ sessions.push({
username, username,
type: parseInt(row.type || '0', 10), type: parseInt(row.type || '0', 10),
@@ -233,13 +253,13 @@ class ChatService {
sortTimestamp: sortTs, sortTimestamp: sortTs,
lastTimestamp: lastTs, lastTimestamp: lastTs,
lastMsgType, lastMsgType,
displayName: username displayName,
avatarUrl
}) })
} }
// 获取联系人信息 // 不等待联系人信息加载,直接返回基础会话列表
await this.enrichSessionsWithContacts(sessions) // 前端可以异步调用 enrichSessionsWithContacts 来补充信息
return { success: true, sessions } return { success: true, sessions }
} catch (e) { } catch (e) {
console.error('ChatService: 获取会话列表失败:', e) console.error('ChatService: 获取会话列表失败:', e)
@@ -248,45 +268,89 @@ class ChatService {
} }
/** /**
* 补充联系人信息 * 异步补充会话列表的联系人信息(公开方法,供前端调用)
*/
async enrichSessionsContactInfo(usernames: string[]): Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}> {
try {
if (usernames.length === 0) {
return { success: true, contacts: {} }
}
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error }
}
const now = Date.now()
const missing: string[] = []
const result: Record<string, { displayName?: string; avatarUrl?: string }> = {}
const updatedEntries: Record<string, ContactCacheEntry> = {}
// 检查缓存
for (const username of usernames) {
const cached = this.avatarCache.get(username)
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) {
result[username] = {
displayName: cached.displayName,
avatarUrl: cached.avatarUrl
}
} else {
missing.push(username)
}
}
// 批量查询缺失的联系人信息
if (missing.length > 0) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const username of missing) {
const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined
const cacheEntry: ContactCacheEntry = {
displayName: displayName || username,
avatarUrl,
updatedAt: now
}
result[username] = { displayName, avatarUrl }
// 更新缓存并记录持久化
this.avatarCache.set(username, cacheEntry)
updatedEntries[username] = cacheEntry
}
if (Object.keys(updatedEntries).length > 0) {
this.contactCacheService.setEntries(updatedEntries)
}
}
return { success: true, contacts: result }
} catch (e) {
console.error('ChatService: 补充联系人信息失败:', e)
return { success: false, error: String(e) }
}
}
/**
* 补充联系人信息(私有方法,保持向后兼容)
*/ */
private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> { private async enrichSessionsWithContacts(sessions: ChatSession[]): Promise<void> {
if (sessions.length === 0) return if (sessions.length === 0) return
try { try {
const now = Date.now() const usernames = sessions.map(s => s.username)
const missing: string[] = [] const result = await this.enrichSessionsContactInfo(usernames)
if (result.success && result.contacts) {
for (const session of sessions) { for (const session of sessions) {
const cached = this.avatarCache.get(session.username) const contact = result.contacts![session.username]
if (cached && now - cached.updatedAt < this.avatarCacheTtlMs) { if (contact) {
if (cached.displayName) session.displayName = cached.displayName if (contact.displayName) session.displayName = contact.displayName
if (cached.avatarUrl) { if (contact.avatarUrl) session.avatarUrl = contact.avatarUrl
session.avatarUrl = cached.avatarUrl
continue
} }
} }
missing.push(session.username)
}
if (missing.length === 0) return
const missingSet = new Set(missing)
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(missing),
wcdbService.getAvatarUrls(missing)
])
for (const session of sessions) {
if (!missingSet.has(session.username)) continue
const displayName = displayNames.success && displayNames.map ? displayNames.map[session.username] : undefined
const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[session.username] : undefined
if (displayName) session.displayName = displayName
if (avatarUrl) session.avatarUrl = avatarUrl
this.avatarCache.set(session.username, {
displayName: session.displayName,
avatarUrl: session.avatarUrl,
updatedAt: now
})
} }
} catch (e) { } catch (e) {
console.error('ChatService: 获取联系人信息失败:', e) console.error('ChatService: 获取联系人信息失败:', e)
@@ -405,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)
@@ -412,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
*/ */
@@ -588,24 +667,24 @@ 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', 'msg_content',
'msg_content', 'msgContent',
'msgContent', 'WCDB_CT_message_content',
'WCDB_CT_message_content', 'WCDB_CT_messageContent'
'WCDB_CT_messageContent' ]);
]), const rawCompressContent = this.getRowField(row, [
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)
@@ -617,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}`)
} }
} }
@@ -721,7 +810,7 @@ class ChatService {
case 49: case 49:
return this.parseType49(content) return this.parseType49(content)
case 50: case 50:
return '[通话]' return this.parseVoipMessage(content)
case 10000: case 10000:
return this.cleanSystemMessage(content) return this.cleanSystemMessage(content)
case 244813135921: case 244813135921:
@@ -847,6 +936,67 @@ class ChatService {
} }
} }
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
* msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等
*/
private parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type0=视频1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
// 已接听的通话,提取时长
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
// 其他状态直接使用 msg 内容
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
console.error('[ChatService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined { private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
const packed = this.getRowField(row, [ const packed = this.getRowField(row, [
'packed_info_data', 'packed_info_data',
@@ -980,6 +1130,118 @@ class ChatService {
} }
} }
//手动查找 media_*.db 文件(当 WCDB DLL 不支持 listMediaDbs 时的 fallback
private async findMediaDbsManually(): Promise<string[]> {
try {
const dbPath = this.configService.get('dbPath')
const myWxid = this.configService.get('myWxid')
if (!dbPath || !myWxid) return []
// 可能的目录结构:
// 1. dbPath 直接指向 db_storage: D:\weixin\WeChat Files\wxid_xxx\db_storage
// 2. dbPath 指向账号目录: D:\weixin\WeChat Files\wxid_xxx
// 3. dbPath 指向 WeChat Files: D:\weixin\WeChat Files
// 4. dbPath 指向微信根目录: D:\weixin
// 5. dbPath 指向非标准目录: D:\weixin\xwechat_files
const searchDirs: string[] = []
// 尝试1: dbPath 本身就是 db_storage
if (basename(dbPath).toLowerCase() === 'db_storage') {
searchDirs.push(dbPath)
}
// 尝试2: dbPath/db_storage
const dbStorage1 = join(dbPath, 'db_storage')
if (existsSync(dbStorage1)) {
searchDirs.push(dbStorage1)
}
// 尝试3: dbPath/WeChat Files/[wxid]/db_storage
const wechatFiles = join(dbPath, 'WeChat Files')
if (existsSync(wechatFiles)) {
const wxidDir = join(wechatFiles, myWxid)
if (existsSync(wxidDir)) {
const dbStorage2 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage2)) {
searchDirs.push(dbStorage2)
}
}
}
// 尝试4: 如果 dbPath 已经包含 WeChat Files直接在其中查找
if (dbPath.includes('WeChat Files')) {
const parts = dbPath.split(path.sep)
const wechatFilesIndex = parts.findIndex(p => p === 'WeChat Files')
if (wechatFilesIndex >= 0) {
const wechatFilesPath = parts.slice(0, wechatFilesIndex + 1).join(path.sep)
const wxidDir = join(wechatFilesPath, myWxid)
if (existsSync(wxidDir)) {
const dbStorage3 = join(wxidDir, 'db_storage')
if (existsSync(dbStorage3) && !searchDirs.includes(dbStorage3)) {
searchDirs.push(dbStorage3)
}
}
}
}
// 尝试5: 直接尝试 dbPath/[wxid]/db_storage (适用于 xwechat_files 等非标准目录名)
const wxidDirDirect = join(dbPath, myWxid)
if (existsSync(wxidDirDirect)) {
const dbStorage5 = join(wxidDirDirect, 'db_storage')
if (existsSync(dbStorage5) && !searchDirs.includes(dbStorage5)) {
searchDirs.push(dbStorage5)
}
}
// 在所有可能的目录中查找 media_*.db
const mediaDbFiles: string[] = []
for (const dir of searchDirs) {
if (!existsSync(dir)) continue
// 直接在当前目录查找
const entries = readdirSync(dir)
for (const entry of entries) {
if (entry.toLowerCase().startsWith('media_') && entry.toLowerCase().endsWith('.db')) {
const fullPath = join(dir, entry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
// 也检查子目录(特别是 message 子目录)
for (const entry of entries) {
const subDir = join(dir, entry)
if (existsSync(subDir) && statSync(subDir).isDirectory()) {
try {
const subEntries = readdirSync(subDir)
for (const subEntry of subEntries) {
if (subEntry.toLowerCase().startsWith('media_') && subEntry.toLowerCase().endsWith('.db')) {
const fullPath = join(subDir, subEntry)
if (existsSync(fullPath) && statSync(fullPath).isFile()) {
if (!mediaDbFiles.includes(fullPath)) {
mediaDbFiles.push(fullPath)
}
}
}
}
} catch (e) {
// 忽略无法访问的子目录
}
}
}
}
return mediaDbFiles
} catch (e) {
console.error('[ChatService] 手动查找 media 数据库失败:', e)
return []
}
}
private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] { private getVoiceLookupCandidates(sessionId: string, msg: Message): string[] {
const candidates: string[] = [] const candidates: string[] = []
const add = (value?: string | null) => { const add = (value?: string | null) => {
@@ -1229,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
} }
@@ -1239,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))
} }
// 如果是字符串 // 如果是字符串
@@ -1255,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
} }
} }
@@ -1263,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 { }
} }
@@ -1277,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 {
@@ -1304,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 || ''
} }
} }
@@ -1386,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
@@ -1409,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] // 按优先级尝试匹配
return { success: true, avatarUrl } const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self']
if (avatarUrl) {
console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`)
return { success: true, avatarUrl }
}
console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`)
return { success: true, avatarUrl: undefined }
} }
console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`)
return { success: true, avatarUrl: undefined } return { success: true, avatarUrl: undefined }
} catch (e) { } catch (e) {
console.error('ChatService: 获取当前用户头像失败:', e) console.error('ChatService: 获取当前用户头像失败:', e)
@@ -1833,13 +2124,20 @@ class ChatService {
}) })
// 2. 查找所有的 media_*.db // 2. 查找所有的 media_*.db
const mediaDbs = await wcdbService.listMediaDbs() let mediaDbs = await wcdbService.listMediaDbs()
if (!mediaDbs.success || !mediaDbs.data) return { success: false, error: '获取媒体库失败' } // Fallback: 如果 WCDB DLL 不支持 listMediaDbs手动查找
console.info('[ChatService][Voice] media dbs', mediaDbs.data) if (!mediaDbs.success || !mediaDbs.data || mediaDbs.data.length === 0) {
const manualMediaDbs = await this.findMediaDbsManually()
if (manualMediaDbs.length > 0) {
mediaDbs = { success: true, data: manualMediaDbs }
} else {
return { success: false, error: '未找到媒体库文件 (media_*.db)' }
}
}
// 3. 在所有媒体库中查找该消息的语音数据 // 3. 在所有媒体库中查找该消息的语音数据
let silkData: Buffer | null = null let silkData: Buffer | null = null
for (const dbPath of mediaDbs.data) { for (const dbPath of (mediaDbs.data || [])) {
const voiceTable = await this.resolveVoiceInfoTableName(dbPath) const voiceTable = await this.resolveVoiceInfoTableName(dbPath)
if (!voiceTable) { if (!voiceTable) {
console.warn('[ChatService][Voice] voice table not found', dbPath) console.warn('[ChatService][Voice] voice table not found', dbPath)
@@ -2165,7 +2463,7 @@ class ChatService {
.prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`) .prepare(`SELECT dir_name FROM ${state.dirTable} WHERE dir_id = ? AND username = ? LIMIT 1`)
.get(dir2, sessionId) as { dir_name?: string } | undefined .get(dir2, sessionId) as { dir_name?: string } | undefined
if (dirRow?.dir_name) dirName = dirRow.dir_name as string if (dirRow?.dir_name) dirName = dirRow.dir_name as string
} catch {} } catch { }
} }
const fullPath = join(accountDir, dir1, dirName, fileName) const fullPath = join(accountDir, dir1, dirName, fileName)
@@ -2173,7 +2471,7 @@ class ChatService {
const withDat = `${fullPath}.dat` const withDat = `${fullPath}.dat`
if (existsSync(withDat)) return withDat if (existsSync(withDat)) return withDat
} catch {} } catch { }
return null return null
} }
@@ -2231,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)
@@ -2244,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)
} }

View File

@@ -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: ''
} }
}) })
} }

View File

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

View File

@@ -19,6 +19,7 @@ interface ChatLabMeta {
platform: string platform: string
type: 'group' | 'private' type: 'group' | 'private'
groupId?: string groupId?: string
groupAvatar?: string
} }
interface ChatLabMember { interface ChatLabMember {
@@ -232,7 +233,7 @@ class ExportService {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
return title || '[链接]' return title || '[链接]'
} }
case 50: return '[通话]' case 50: return this.parseVoipMessage(content)
case 10000: return this.cleanSystemMessage(content) case 10000: return this.cleanSystemMessage(content)
default: default:
if (content.includes('<type>57</type>')) { if (content.includes('<type>57</type>')) {
@@ -264,6 +265,64 @@ class ExportService {
.trim() || '[系统消息]' .trim() || '[系统消息]'
} }
/**
* 解析通话消息
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
* room_type: 0 = 语音通话, 1 = 视频通话
*/
private parseVoipMessage(content: string): string {
try {
if (!content) return '[通话]'
// 提取 msg 内容(中文通话状态)
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
const msg = msgMatch?.[1]?.trim() || ''
// 提取 room_type0=视频1=语音)
const roomTypeMatch = /<room_type>(\d+)<\/room_type>/i.exec(content)
const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1
// 构建通话类型标签
let callType: string
if (roomType === 0) {
callType = '视频通话'
} else if (roomType === 1) {
callType = '语音通话'
} else {
callType = '通话'
}
// 解析通话状态
if (msg.includes('通话时长')) {
const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg)
const duration = durationMatch?.[1] || ''
if (duration) {
return `[${callType}] ${duration}`
}
return `[${callType}] 已接听`
} else if (msg.includes('对方无应答')) {
return `[${callType}] 对方无应答`
} else if (msg.includes('已取消')) {
return `[${callType}] 已取消`
} else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) {
return `[${callType}] 已在其他设备接听`
} else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) {
return `[${callType}] 对方已拒绝`
} else if (msg.includes('忙线未接听') || msg.includes('忙线')) {
return `[${callType}] 忙线未接听`
} else if (msg.includes('未接听')) {
return `[${callType}] 未接听`
} else if (msg) {
return `[${callType}] ${msg}`
}
return `[${callType}]`
} catch (e) {
console.error('[ExportService] Failed to parse VOIP message:', e)
return '[通话]'
}
}
/** /**
* 获取消息类型名称 * 获取消息类型名称
*/ */
@@ -367,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:')) {
@@ -376,8 +510,8 @@ class ExportService {
const data = Buffer.from(match[2], 'base64') const data = Buffer.from(match[2], 'base64')
const ext = mime.includes('png') ? '.png' const ext = mime.includes('png') ? '.png'
: mime.includes('gif') ? '.gif' : mime.includes('gif') ? '.gif'
: mime.includes('webp') ? '.webp' : mime.includes('webp') ? '.webp'
: '.jpg' : '.jpg'
return { data, ext, mime } return { data, ext, mime }
} }
if (avatarUrl.startsWith('file://')) { if (avatarUrl.startsWith('file://')) {
@@ -456,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 {
@@ -467,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':
@@ -509,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)
@@ -522,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)
@@ -545,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
@@ -560,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
@@ -671,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
} }
@@ -695,7 +874,7 @@ class ExportService {
...detailedExport.session, ...detailedExport.session,
avatar: avatars[sessionId] avatar: avatars[sessionId]
} }
;(detailedExport as any).avatars = avatars ; (detailedExport as any).avatars = avatars
} }
} }

View File

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

View File

@@ -1,14 +1,14 @@
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'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
type DecryptResult = { type DecryptResult = {
success: boolean success: boolean
localPath?: string localPath?: string
error?: string error?: string
@@ -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: '未找到账号目录' }
} }
@@ -139,15 +179,19 @@ export class ImageDecryptService {
payload.sessionId, payload.sessionId,
{ allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) } { allowThumbnail: !payload.force, skipResolvedCache: Boolean(payload.force) }
) )
// 如果要求高清图但没找到,直接返回提示 // 如果要求高清图但没找到,直接返回提示
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,13 +237,15 @@ 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)
if (!isThumb) { if (!isThumb) {
@@ -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
} }
@@ -244,10 +292,10 @@ export class ImageDecryptService {
private getDecryptedCacheDir(wxid: string): string | null { private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath') const cachePath = this.configService.get('cachePath')
if (!cachePath) return null if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid) const cleanedWxid = this.cleanAccountDirName(wxid)
const cacheAccountDir = join(cachePath, cleanedWxid) const cacheAccountDir = join(cachePath, cleanedWxid)
// 检查缓存目录下是否有 hardlink.db // 检查缓存目录下是否有 hardlink.db
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { if (existsSync(join(cacheAccountDir, 'hardlink.db'))) {
return cacheAccountDir return cacheAccountDir
@@ -312,7 +360,7 @@ export class ImageDecryptService {
allowThumbnail, allowThumbnail,
skipResolvedCache skipResolvedCache
}) })
// 优先通过 hardlink.db 查询 // 优先通过 hardlink.db 查询
if (imageMd5) { if (imageMd5) {
this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId }) this.logInfo('[ImageDecrypt] hardlink lookup (md5)', { imageMd5, sessionId })
@@ -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 {
@@ -528,7 +576,7 @@ export class ImageDecryptService {
this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable }) this.logInfo('[ImageDecrypt] hardlink row miss', { md5, table: state.imageTable })
return null return null
} }
const dir1 = this.getRowValue(row, 'dir1') const dir1 = this.getRowValue(row, 'dir1')
const dir2 = this.getRowValue(row, 'dir2') const dir2 = this.getRowValue(row, 'dir2')
const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName') const fileName = this.getRowValue(row, 'file_name') ?? this.getRowValue(row, 'fileName')
@@ -549,7 +597,7 @@ export class ImageDecryptService {
// dir1 和 dir2 是 rowid需要从 dir2id 表查询对应的目录名 // dir1 和 dir2 是 rowid需要从 dir2id 表查询对应的目录名
let dir1Name: string | null = null let dir1Name: string | null = null
let dir2Name: string | null = null let dir2Name: string | null = null
if (state.dirTable) { if (state.dirTable) {
try { try {
// 通过 rowid 查询目录名 // 通过 rowid 查询目录名
@@ -562,7 +610,7 @@ export class ImageDecryptService {
const value = this.getRowValue(dir1Result.rows[0], 'username') const value = this.getRowValue(dir1Result.rows[0], 'username')
if (value) dir1Name = String(value) if (value) dir1Name = String(value)
} }
const dir2Result = await wcdbService.execQuery( const dir2Result = await wcdbService.execQuery(
'media', 'media',
hardlinkPath, hardlinkPath,
@@ -588,14 +636,14 @@ export class ImageDecryptService {
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, 'mg', fileName),
join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName), join(accountDir, 'msg', 'attach', dir1Name, dir2Name, fileName),
] ]
for (const fullPath of possiblePaths) { for (const fullPath of possiblePaths) {
if (existsSync(fullPath)) { if (existsSync(fullPath)) {
this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath }) this.logInfo('[ImageDecrypt] hardlink path hit', { fullPath })
return fullPath return fullPath
} }
} }
this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths }) this.logInfo('[ImageDecrypt] hardlink path miss', { possiblePaths })
return null return null
} catch { } catch {
@@ -829,14 +877,14 @@ export class ImageDecryptService {
} }
} }
} }
// 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg // 新目录结构: Images/{normalizedKey}/{normalizedKey}_thumb.jpg 或 _hd.jpg
const imageDir = join(root, normalizedKey) const imageDir = join(root, normalizedKey)
if (existsSync(imageDir)) { if (existsSync(imageDir)) {
const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd) const hit = this.findCachedOutputInDir(imageDir, normalizedKey, extensions, preferHd)
if (hit) return hit if (hit) return hit
} }
// 兼容旧的平铺结构 // 兼容旧的平铺结构
for (const ext of extensions) { for (const ext of extensions) {
const candidate = join(root, `${cacheKey}${ext}`) const candidate = join(root, `${cacheKey}${ext}`)
@@ -846,7 +894,7 @@ export class ImageDecryptService {
const candidate = join(root, `${cacheKey}_t${ext}`) const candidate = join(root, `${cacheKey}_t${ext}`)
if (existsSync(candidate)) return candidate if (existsSync(candidate)) return candidate
} }
return null return null
} }
@@ -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
@@ -875,14 +925,14 @@ export class ImageDecryptService {
const name = basename(datPath) const name = basename(datPath)
const lower = name.toLowerCase() const lower = name.toLowerCase()
const base = lower.endsWith('.dat') ? name.slice(0, -4) : name const base = lower.endsWith('.dat') ? name.slice(0, -4) : name
// 提取基础名称(去掉 _t, _h 等后缀) // 提取基础名称(去掉 _t, _h 等后缀)
const normalizedBase = this.normalizeDatBase(base) const normalizedBase = this.normalizeDatBase(base)
// 判断是缩略图还是高清图 // 判断是缩略图还是高清图
const isThumb = this.isThumbnailDat(lower) const isThumb = this.isThumbnailDat(lower)
const suffix = isThumb ? '_thumb' : '_hd' const suffix = isThumb ? '_thumb' : '_hd'
const contactDir = this.sanitizeDirName(sessionId || 'unknown') const contactDir = this.sanitizeDirName(sessionId || 'unknown')
const timeDir = this.resolveTimeDir(datPath) const timeDir = this.resolveTimeDir(datPath)
const outputDir = join(this.getCacheRoot(), contactDir, timeDir) const outputDir = join(this.getCacheRoot(), contactDir, timeDir)
@@ -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)
} }
@@ -1079,7 +1131,7 @@ export class ImageDecryptService {
private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> { private async decryptDatAuto(datPath: string, xorKey: number, aesKey: Buffer | null): Promise<Buffer> {
const version = this.getDatVersion(datPath) const version = this.getDatVersion(datPath)
if (version === 0) { if (version === 0) {
return this.decryptDatV3(datPath, xorKey) return this.decryptDatV3(datPath, xorKey)
} }
@@ -1136,7 +1188,7 @@ export class ImageDecryptService {
// 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充 // 当 aesSize % 16 === 0 时,仍需要额外 16 字节的填充
const remainder = ((aesSize % 16) + 16) % 16 const remainder = ((aesSize % 16) + 16) % 16
const alignedAesSize = aesSize + (16 - remainder) const alignedAesSize = aesSize + (16 - remainder)
if (alignedAesSize > data.length) { if (alignedAesSize > data.length) {
throw new Error('文件格式异常AES 数据长度超过文件实际长度') throw new Error('文件格式异常AES 数据长度超过文件实际长度')
} }
@@ -1147,7 +1199,7 @@ export class ImageDecryptService {
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null) const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false) decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()]) const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
// 使用 PKCS7 填充移除 // 使用 PKCS7 填充移除
unpadded = this.strictRemovePadding(decrypted) unpadded = this.strictRemovePadding(decrypted)
} }
@@ -1214,7 +1266,7 @@ export class ImageDecryptService {
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png' if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) return '.png'
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg' if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 && if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) { buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
return '.webp' return '.webp'
} }
return null return null
@@ -1332,10 +1384,10 @@ export class ImageDecryptService {
keyCount.set(key, (keyCount.get(key) || 0) + 1) keyCount.set(key, (keyCount.get(key) || 0) + 1)
filesChecked++ filesChecked++
} }
} catch {} } catch { }
} }
} }
} catch {} } catch { }
} }
scanDir(dirPath) scanDir(dirPath)

View File

@@ -1,9 +1,10 @@
import { app } from 'electron' import { app } from 'electron'
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import { existsSync, readdirSync, readFileSync, statSync } from 'fs' import { existsSync, readdirSync, readFileSync, statSync, copyFileSync, mkdirSync } from 'fs'
import { execFile, spawn } from 'child_process' import { execFile, spawn } from 'child_process'
import { promisify } from 'util' import { promisify } from 'util'
import crypto from 'crypto' import crypto from 'crypto'
import os from 'os'
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
@@ -57,18 +58,94 @@ export class KeyService {
private readonly ERROR_SUCCESS = 0 private readonly ERROR_SUCCESS = 0
private getDllPath(): string { private getDllPath(): string {
const resourcesPath = app.isPackaged const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources') // 候选路径列表
return join(resourcesPath, 'wx_key.dll') const candidates: string[] = []
// 1. 显式环境变量 (最高优先级)
if (process.env.WX_KEY_DLL_PATH) {
candidates.push(process.env.WX_KEY_DLL_PATH)
}
if (isPackaged) {
// 生产环境: 通常在 resources 目录下,但也可能直接在 resources 根目录
candidates.push(join(process.resourcesPath, 'resources', 'wx_key.dll'))
candidates.push(join(process.resourcesPath, 'wx_key.dll'))
} else {
// 开发环境
const cwd = process.cwd()
candidates.push(join(cwd, 'resources', 'wx_key.dll'))
candidates.push(join(app.getAppPath(), 'resources', 'wx_key.dll'))
}
// 检查并返回第一个存在的路径
for (const path of candidates) {
if (existsSync(path)) {
return path
}
}
// 如果都没找到,返回最可能的路径以便报错信息有参考
return candidates[0]
}
// 检查路径是否为 UNC 路径或网络路径
private isNetworkPath(path: string): boolean {
// UNC 路径以 \\ 开头
if (path.startsWith('\\\\')) {
return true
}
// 检查是否为网络映射驱动器简化检测A: 表示驱动器)
// 注意:这是一个启发式检测,更准确的方式需要调用 GetDriveType Windows API
// 但对于大多数 VM 共享场景UNC 路径检测已足够
return false
}
// 将 DLL 复制到本地临时目录
private localizeNetworkDll(originalPath: string): string {
try {
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
const localPath = join(tempDir, 'wx_key.dll')
// 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath
}
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath
} catch (e) {
console.error('DLL 本地化失败:', e)
// 如果本地化失败,返回原路径
return originalPath
}
} }
private ensureLoaded(): boolean { private ensureLoaded(): boolean {
if (this.initialized) return true if (this.initialized) return true
let dllPath = ''
try { try {
this.koffi = require('koffi') this.koffi = require('koffi')
const dllPath = this.getDllPath() dllPath = this.getDllPath()
if (!existsSync(dllPath)) return false
if (!existsSync(dllPath)) {
console.error(`wx_key.dll 不存在于路径: ${dllPath}`)
return false
}
// 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath)
}
this.lib = this.koffi.load(dllPath) this.lib = this.koffi.load(dllPath)
this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)') this.initHook = this.lib.func('bool InitializeHook(uint32 targetPid)')
@@ -80,7 +157,14 @@ export class KeyService {
this.initialized = true this.initialized = true
return true return true
} catch (e) { } catch (e) {
console.error('加载 wx_key.dll 失败:', e) const errorMsg = e instanceof Error ? e.message : String(e)
const errorStack = e instanceof Error ? e.stack : ''
console.error(`加载 wx_key.dll 失败`)
console.error(` 路径: ${dllPath}`)
console.error(` 错误: ${errorMsg}`)
if (errorStack) {
console.error(` 堆栈: ${errorStack}`)
}
return false return false
} }
} }
@@ -695,33 +779,41 @@ export class KeyService {
} }
private getXorKey(templateFiles: string[]): number | null { private getXorKey(templateFiles: string[]): number | null {
const counts = new Map<string, number>() const counts = new Map<number, number>()
const tailSignatures = [
Buffer.from([0xFF, 0xD9]),
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
]
for (const file of templateFiles) { for (const file of templateFiles) {
try { try {
const bytes = readFileSync(file) const bytes = readFileSync(file)
if (bytes.length < 2) continue for (const signature of tailSignatures) {
const x = bytes[bytes.length - 2] if (bytes.length < signature.length) continue
const y = bytes[bytes.length - 1] const tail = bytes.subarray(bytes.length - signature.length)
const key = `${x}_${y}` const xorKey = tail[0] ^ signature[0]
counts.set(key, (counts.get(key) ?? 0) + 1) let valid = true
for (let i = 1; i < signature.length; i++) {
if ((tail[i] ^ xorKey) !== signature[i]) {
valid = false
break
}
}
if (valid) {
counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1)
}
}
} catch { } } catch { }
} }
if (!counts.size) return null if (!counts.size) return null
let mostKey = '' let bestKey: number | null = null
let mostCount = 0 let bestCount = 0
for (const [key, count] of counts) { for (const [key, count] of counts) {
if (count > mostCount) { if (count > bestCount) {
mostCount = count bestCount = count
mostKey = key bestKey = key
} }
} }
if (!mostKey) return null return bestKey
const [xStr, yStr] = mostKey.split('_')
const x = Number(xStr)
const y = Number(yStr)
const xorKey = x ^ 0xFF
const check = y ^ 0xD9
return xorKey === check ? xorKey : null
} }
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
@@ -766,7 +858,17 @@ export class KeyService {
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
decipher.setAutoPadding(false) decipher.setAutoPadding(false)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff
const isPng = decrypted.length >= 8 &&
decrypted[0] === 0x89 &&
decrypted[1] === 0x50 &&
decrypted[2] === 0x4e &&
decrypted[3] === 0x47 &&
decrypted[4] === 0x0d &&
decrypted[5] === 0x0a &&
decrypted[6] === 0x1a &&
decrypted[7] === 0x0a
return isJpeg || isPng
} catch { } catch {
return false return false
} }
@@ -813,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) {
@@ -878,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 {
@@ -915,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,

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

122
electron/wcdbWorker.ts Normal file
View File

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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "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",

View File

@@ -1,16 +1,17 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.0.1", "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",
@@ -64,6 +65,7 @@
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"differentialPackage": false,
"allowToChangeInstallationDirectory": true, "allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true, "createDesktopShortcut": true,
"unicode": true, "unicode": true,
@@ -97,4 +99,4 @@
"dist-electron/**/*" "dist-electron/**/*"
] ]
} }
} }

Binary file not shown.

View File

@@ -7,6 +7,7 @@ import WelcomePage from './pages/WelcomePage'
import HomePage from './pages/HomePage' import HomePage from './pages/HomePage'
import ChatPage from './pages/ChatPage' import ChatPage from './pages/ChatPage'
import AnalyticsPage from './pages/AnalyticsPage' import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage'
import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportPage from './pages/AnnualReportPage'
import AnnualReportWindow from './pages/AnnualReportWindow' import AnnualReportWindow from './pages/AnnualReportWindow'
import AgreementPage from './pages/AgreementPage' import AgreementPage from './pages/AgreementPage'
@@ -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'
@@ -188,7 +190,7 @@ function App() {
} }
console.log('检测到已保存的配置,正在自动连接...') console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect() const result = await window.electronAPI.chat.connect()
if (result.success) { if (result.success) {
console.log('自动连接成功') console.log('自动连接成功')
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
@@ -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 />} />

View File

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

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

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

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { 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"
@@ -84,10 +86,10 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
<NavLink <NavLink
to="/settings" to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`} className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined} title={collapsed ? '设置' : undefined}
> >
@@ -96,8 +98,8 @@ function Sidebar() {
</span> </span>
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
<button <button
className="collapse-btn" className="collapse-btn"
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'} title={collapsed ? '展开菜单' : '收起菜单'}

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import { Loader2, Download, Image, Check, X } from 'lucide-react' import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react'
import html2canvas from 'html2canvas' import html2canvas from 'html2canvas'
import { useThemeStore } from '../stores/themeStore' import { useThemeStore } from '../stores/themeStore'
import './AnnualReportWindow.scss' import './AnnualReportWindow.scss'
@@ -249,6 +249,7 @@ function AnnualReportWindow() {
const [fabOpen, setFabOpen] = useState(false) const [fabOpen, setFabOpen] = useState(false)
const [loadingProgress, setLoadingProgress] = useState(0) const [loadingProgress, setLoadingProgress] = useState(0)
const [loadingStage, setLoadingStage] = useState('正在初始化...') const [loadingStage, setLoadingStage] = useState('正在初始化...')
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
const { currentTheme, themeMode } = useThemeStore() const { currentTheme, themeMode } = useThemeStore()
@@ -490,7 +491,7 @@ function AnnualReportWindow() {
} }
// 导出整个报告为长图 // 导出整个报告为长图
const exportFullReport = async () => { const exportFullReport = async (filterIds?: Set<string>) => {
if (!containerRef.current) { if (!containerRef.current) {
return return
} }
@@ -516,6 +517,16 @@ function AnnualReportWindow() {
el.style.padding = '40px 0' el.style.padding = '40px 0'
}) })
// 如果有筛选,隐藏未选中的板块
if (filterIds) {
const available = getAvailableSections()
available.forEach(s => {
if (!filterIds.has(s.id) && s.ref.current) {
s.ref.current.style.display = 'none'
}
})
}
// 修复词云导出问题 // 修复词云导出问题
const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement> const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
@@ -584,7 +595,7 @@ function AnnualReportWindow() {
const dataUrl = outputCanvas.toDataURL('image/png') const dataUrl = outputCanvas.toDataURL('image/png')
const link = document.createElement('a') const link = document.createElement('a')
link.download = `${reportData?.year}年度报告.png` link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl link.href = dataUrl
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -607,6 +618,13 @@ function AnnualReportWindow() {
return return
} }
if (exportMode === 'long') {
setShowExportModal(false)
await exportFullReport(selectedSections)
setSelectedSections(new Set())
return
}
setIsExporting(true) setIsExporting(true)
setShowExportModal(false) setShowExportModal(false)
@@ -735,9 +753,12 @@ function AnnualReportWindow() {
{/* 浮动操作按钮 */} {/* 浮动操作按钮 */}
<div className={`fab-container ${fabOpen ? 'open' : ''}`}> <div className={`fab-container ${fabOpen ? 'open' : ''}`}>
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出"> <button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
<Image size={18} /> <Image size={18} />
</button> </button>
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
<SlidersHorizontal size={18} />
</button>
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图"> <button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
<Download size={18} /> <Download size={18} />
</button> </button>
@@ -765,7 +786,7 @@ function AnnualReportWindow() {
<div className="export-overlay" onClick={() => setShowExportModal(false)}> <div className="export-overlay" onClick={() => setShowExportModal(false)}>
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}> <div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h3></h3> <h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
<button className="close-btn" onClick={() => setShowExportModal(false)}> <button className="close-btn" onClick={() => setShowExportModal(false)}>
<X size={20} /> <X size={20} />
</button> </button>
@@ -793,7 +814,7 @@ function AnnualReportWindow() {
onClick={exportSelectedSections} onClick={exportSelectedSections}
disabled={selectedSections.size === 0} disabled={selectedSections.size === 0}
> >
{selectedSections.size > 0 ? `(${selectedSections.size})` : ''} {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
</button> </button>
</div> </div>
</div> </div>
@@ -838,7 +859,7 @@ function AnnualReportWindow() {
<span className="hl">{formatNumber(topFriend.sentCount)}</span> · <span className="hl">{formatNumber(topFriend.sentCount)}</span> ·
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span>
</p> </p>
<br/> <br />
<p className="hero-desc"> <p className="hero-desc">
</p> </p>

View File

@@ -883,6 +883,23 @@
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
position: relative;
&.loading .message-list {
opacity: 0;
transform: translateY(8px);
pointer-events: none;
}
&.loaded .message-list {
opacity: 1;
transform: translateY(0);
}
&.loaded .loading-overlay {
opacity: 0;
pointer-events: none;
}
} }
.message-list { .message-list {
@@ -898,6 +915,7 @@
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
position: relative; position: relative;
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
transition: opacity 240ms ease, transform 240ms ease;
// 滚动条样式 // 滚动条样式
&::-webkit-scrollbar { &::-webkit-scrollbar {
@@ -918,6 +936,19 @@
} }
} }
.loading-messages.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
background: rgba(10, 10, 10, 0.28);
backdrop-filter: blur(6px);
transition: opacity 200ms ease;
z-index: 2;
}
.message-list * { .message-list * {
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
} }
@@ -1108,6 +1139,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.6; line-height: 1.6;
word-break: break-word; word-break: break-word;
white-space: pre-wrap;
} }
// 表情包消息 // 表情包消息
@@ -1394,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;
@@ -1432,6 +1466,7 @@
.quoted-text { .quoted-text {
color: var(--text-secondary); color: var(--text-secondary);
white-space: pre-wrap;
} }
} }
@@ -1842,4 +1877,4 @@
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -379,29 +379,21 @@
border-radius: 10px; border-radius: 10px;
font-size: 14px; font-size: 14px;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
}
svg { svg {
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0;
} }
span { span {
flex: 1; flex: 1;
} }
.change-btn {
background: none;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-tertiary);
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: var(--text-primary);
}
}
} }
.media-options { .media-options {
@@ -471,6 +463,43 @@
margin: 8px 0 0; margin: 8px 0 0;
} }
.select-folder-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
margin-top: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
svg {
color: var(--primary);
}
}
&:active {
transform: scale(0.98);
}
svg {
color: var(--text-secondary);
transition: color 0.2s;
}
}
.export-action { .export-action {
padding: 20px 24px; padding: 20px 24px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -649,9 +678,245 @@
} }
} }
} }
.date-picker-modal {
background: var(--card-bg);
padding: 28px 32px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
min-width: 420px;
max-width: 500px;
h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px;
}
.quick-select {
display: flex;
gap: 8px;
margin-bottom: 20px;
.quick-btn {
flex: 1;
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.98);
}
}
}
.date-display {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border-radius: 12px;
margin-bottom: 24px;
.date-display-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(var(--primary-rgb), 0.05);
}
&.active {
background: rgba(var(--primary-rgb), 0.1);
border: 1px solid var(--primary);
}
.date-label {
font-size: 12px;
color: var(--text-tertiary);
font-weight: 500;
}
.date-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.date-separator {
font-size: 14px;
color: var(--text-tertiary);
padding: 0 4px;
}
}
.calendar-container {
margin-bottom: 20px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 0 4px;
.calendar-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
&:active {
transform: scale(0.95);
}
}
.calendar-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
.calendar-weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 8px 0;
}
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):hover {
background: var(--bg-hover);
}
&.in-range {
background: rgba(var(--primary-rgb), 0.08);
}
&.start,
&.end {
background: var(--primary);
color: #fff;
font-weight: 600;
&:hover {
background: var(--primary-hover);
}
}
}
}
.date-picker-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
button {
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:active {
transform: scale(0.98);
}
}
.cancel-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-hover);
}
}
.confirm-btn {
background: var(--primary);
color: #fff;
border: none;
&:hover {
background: var(--primary-hover);
}
}
}
}
} }
@keyframes exportSpin { @keyframes exportSpin {
from { transform: rotate(0deg); } from {
to { transform: rotate(360deg); } transform: rotate(0deg);
} }
to {
transform: rotate(360deg);
}
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react' import { Search, Download, FolderOpen, RefreshCw, Check, Calendar, FileJson, FileText, Table, Loader2, X, ChevronDown, ChevronLeft, ChevronRight, FileSpreadsheet, Database, FileCode, CheckCircle, XCircle, ExternalLink } from 'lucide-react'
import * as configService from '../services/config' import * as configService from '../services/config'
import './ExportPage.scss' import './ExportPage.scss'
@@ -35,7 +35,10 @@ function ExportPage() {
const [isExporting, setIsExporting] = useState(false) const [isExporting, setIsExporting] = useState(false)
const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' }) const [exportProgress, setExportProgress] = useState({ current: 0, total: 0, currentName: '' })
const [exportResult, setExportResult] = useState<ExportResult | null>(null) const [exportResult, setExportResult] = useState<ExportResult | null>(null)
const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [options, setOptions] = useState<ExportOptions>({ const [options, setOptions] = useState<ExportOptions>({
format: 'chatlab', format: 'chatlab',
dateRange: { dateRange: {
@@ -145,7 +148,8 @@ function ExportPage() {
exportAvatars: options.exportAvatars, exportAvatars: options.exportAvatars,
dateRange: options.useAllTime ? null : options.dateRange ? { dateRange: options.useAllTime ? null : options.dateRange ? {
start: Math.floor(options.dateRange.start.getTime() / 1000), start: Math.floor(options.dateRange.start.getTime() / 1000),
end: Math.floor(options.dateRange.end.getTime() / 1000) // 将结束日期设置为当天的 23:59:59,以包含当天的所有消息
end: Math.floor(new Date(options.dateRange.end.getFullYear(), options.dateRange.end.getMonth(), options.dateRange.end.getDate(), 23, 59, 59).getTime() / 1000)
} : null } : null
} }
@@ -167,6 +171,54 @@ function ExportPage() {
} }
} }
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const generateCalendar = () => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateSelect = (day: number) => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth()
const selectedDate = new Date(year, month, day)
if (selectingStart) {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, start: selectedDate } : { start: selectedDate, end: new Date() }
})
setSelectingStart(false)
} else {
setOptions({
...options,
dateRange: options.dateRange ? { ...options.dateRange, end: selectedDate } : { start: new Date(), end: selectedDate }
})
setSelectingStart(true)
}
}
const formatOptions = [ const formatOptions = [
{ value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' }, { value: 'chatlab', label: 'ChatLab', icon: FileCode, desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' }, { value: 'chatlab-jsonl', label: 'ChatLab JSONL', icon: FileCode, desc: '流式格式,适合大量消息' },
@@ -281,12 +333,10 @@ function ExportPage() {
<span></span> <span></span>
</label> </label>
{!options.useAllTime && options.dateRange && ( {!options.useAllTime && options.dateRange && (
<div className="date-range"> <div className="date-range" onClick={() => setShowDatePicker(true)}>
<Calendar size={16} /> <Calendar size={16} />
<span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span> <span>{formatDate(options.dateRange.start)} - {formatDate(options.dateRange.end)}</span>
<button className="change-btn"> <ChevronDown size={14} />
<ChevronDown size={14} />
</button>
</div> </div>
)} )}
</div> </div>
@@ -312,7 +362,26 @@ function ExportPage() {
<FolderOpen size={16} /> <FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span> <span>{exportFolder || '未设置'}</span>
</div> </div>
<p className="path-hint"></p> <button
className="select-folder-btn"
onClick={async () => {
try {
const result = await window.electronAPI.dialog.openFile({
title: '选择导出目录',
properties: ['openDirectory']
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择目录失败:', e)
}
}}
>
<FolderOpen size={16} />
<span></span>
</button>
</div> </div>
</div> </div>
@@ -387,6 +456,130 @@ function ExportPage() {
</div> </div>
</div> </div>
)} )}
{/* 日期选择弹窗 */}
{showDatePicker && (
<div className="export-overlay" onClick={() => setShowDatePicker(false)}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3>
<div className="quick-select">
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 7 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
7
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
30
</button>
<button
className="quick-btn"
onClick={() => {
const end = new Date()
const start = new Date(end.getTime() - 90 * 24 * 60 * 60 * 1000)
setOptions({ ...options, dateRange: { start, end } })
}}
>
90
</button>
</div>
<div className="date-display">
<div
className={`date-display-item ${selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(true)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.start.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
<span className="date-separator"></span>
<div
className={`date-display-item ${!selectingStart ? 'active' : ''}`}
onClick={() => setSelectingStart(false)}
>
<span className="date-label"></span>
<span className="date-value">
{options.dateRange?.end.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})}
</span>
</div>
</div>
<div className="calendar-container">
<div className="calendar-header">
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))}
>
<ChevronLeft size={18} />
</button>
<span className="calendar-month">
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span>
<button
className="calendar-nav-btn"
onClick={() => setCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))}
>
<ChevronRight size={18} />
</button>
</div>
<div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div>
))}
</div>
<div className="calendar-days">
{generateCalendar().map((day, index) => {
if (day === null) {
return <div key={`empty-${index}`} className="calendar-day empty" />
}
const currentDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
const isStart = options.dateRange?.start.toDateString() === currentDate.toDateString()
const isEnd = options.dateRange?.end.toDateString() === currentDate.toDateString()
const isInRange = options.dateRange && currentDate >= options.dateRange.start && currentDate <= options.dateRange.end
return (
<div
key={day}
className={`calendar-day ${isStart ? 'start' : ''} ${isEnd ? 'end' : ''} ${isInRange ? 'in-range' : ''}`}
onClick={() => handleDateSelect(day)}
>
{day}
</div>
)
})}
</div>
</div>
<div className="date-picker-actions">
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}>
</button>
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}>
</button>
</div>
</div>
</div>
)}
</div> </div>
) )
} }

View File

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

View File

@@ -3,19 +3,18 @@ import { useAppStore } from '../stores/appStore'
import { useThemeStore, themes } from '../stores/themeStore' import { useThemeStore, themes } from '../stores/themeStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Save, Plug, Check, Sun, Moon, RotateCcw, Trash2, Save, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw Palette, Database, Download, HardDrive, Info, RefreshCw
} from 'lucide-react' } from 'lucide-react'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'export' | 'cache' | 'about' type SettingsTab = 'appearance' | 'database' | 'cache' | 'about'
const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'appearance', label: '外观', icon: Palette }, { id: 'appearance', label: '外观', icon: Palette },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'export', label: '导出', icon: Download },
{ id: 'cache', label: '缓存', icon: HardDrive }, { id: 'cache', label: '缓存', icon: HardDrive },
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
@@ -31,10 +30,8 @@ function SettingsPage() {
const [dbPath, setDbPath] = useState('') const [dbPath, setDbPath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [exportPath, setExportPath] = useState('')
const [defaultExportPath, setDefaultExportPath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [isLoading, setIsLoadingState] = useState(false) const [isLoading, setIsLoadingState] = useState(false)
const [isTesting, setIsTesting] = useState(false) const [isTesting, setIsTesting] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false)
@@ -53,7 +50,6 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
loadDefaultExportPath()
loadAppVersion() loadAppVersion()
}, []) }, [])
@@ -80,12 +76,11 @@ function SettingsPage() {
const savedLogEnabled = await configService.getLogEnabled() const savedLogEnabled = await configService.getLogEnabled()
const savedImageXorKey = await configService.getImageXorKey() const savedImageXorKey = await configService.getImageXorKey()
const savedImageAesKey = await configService.getImageAesKey() const savedImageAesKey = await configService.getImageAesKey()
if (savedKey) setDecryptKey(savedKey) if (savedKey) setDecryptKey(savedKey)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
if (savedCachePath) setCachePath(savedCachePath) if (savedCachePath) setCachePath(savedCachePath)
if (savedExportPath) setExportPath(savedExportPath)
if (savedImageXorKey != null) { if (savedImageXorKey != null) {
setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`) setImageXorKey(`0x${savedImageXorKey.toString(16).toUpperCase().padStart(2, '0')}`)
} }
@@ -96,14 +91,7 @@ function SettingsPage() {
} }
} }
const loadDefaultExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setDefaultExportPath(downloadsPath)
} catch (e) {
console.error('获取默认导出路径失败:', e)
}
}
const loadAppVersion = async () => { const loadAppVersion = async () => {
try { try {
@@ -166,7 +154,7 @@ function SettingsPage() {
setDbPath(result.path) setDbPath(result.path)
await configService.setDbPath(result.path) await configService.setDbPath(result.path)
showMessage(`自动检测成功:${result.path}`, true) showMessage(`自动检测成功:${result.path}`, true)
const wxids = await window.electronAPI.dbPath.scanWxids(result.path) const wxids = await window.electronAPI.dbPath.scanWxids(result.path)
if (wxids.length === 1) { if (wxids.length === 1) {
setWxid(wxids[0].wxid) setWxid(wxids[0].wxid)
@@ -230,18 +218,7 @@ function SettingsPage() {
} }
} }
const handleSelectExportPath = async () => {
try {
const result = await dialog.openFile({ title: '选择导出目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) {
setExportPath(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
showMessage('已设置导出目录', true)
}
} catch (e) {
showMessage('选择目录失败', false)
}
}
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
@@ -303,16 +280,7 @@ function SettingsPage() {
} }
} }
const handleResetExportPath = async () => {
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportPath(downloadsPath)
await configService.setExportPath(downloadsPath)
showMessage('已恢复为下载目录', true)
} catch (e) {
showMessage('恢复默认失败', false)
}
}
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!dbPath) { showMessage('请先选择数据库目录', false); return } if (!dbPath) { showMessage('请先选择数据库目录', false); return }
@@ -396,7 +364,6 @@ function SettingsPage() {
setDbPath('') setDbPath('')
setWxid('') setWxid('')
setCachePath('') setCachePath('')
setExportPath('')
setLogEnabled(false) setLogEnabled(false)
setDbConnected(false) setDbConnected(false)
await window.electronAPI.window.openOnboardingWindow() await window.electronAPI.window.openOnboardingWindow()
@@ -517,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">
@@ -562,19 +530,7 @@ function SettingsPage() {
</div> </div>
) )
const renderExportTab = () => (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<input type="text" placeholder={defaultExportPath || '系统下载目录'} value={exportPath || defaultExportPath} onChange={(e) => setExportPath(e.target.value)} />
<div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectExportPath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={handleResetExportPath}><RotateCcw size={16} /> </button>
</div>
</div>
</div>
)
const renderCacheTab = () => ( const renderCacheTab = () => (
<div className="tab-content"> <div className="tab-content">
@@ -603,7 +559,7 @@ function SettingsPage() {
<h2 className="about-name">WeFlow</h2> <h2 className="about-name">WeFlow</h2>
<p className="about-slogan">WeFlow</p> <p className="about-slogan">WeFlow</p>
<p className="about-version">v{appVersion || '...'}</p> <p className="about-version">v{appVersion || '...'}</p>
<div className="about-update"> <div className="about-update">
{updateInfo?.hasUpdate ? ( {updateInfo?.hasUpdate ? (
<> <>
@@ -672,7 +628,6 @@ function SettingsPage() {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'export' && renderExportTab()}
{activeTab === 'cache' && renderCacheTab()} {activeTab === 'cache' && renderCacheTab()}
{activeTab === 'about' && renderAboutTab()} {activeTab === 'about' && renderAboutTab()}
</div> </div>

View File

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

View File

@@ -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()

View File

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

View File

@@ -45,6 +45,7 @@ export interface ElectronAPI {
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }> testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean> open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
close: () => Promise<boolean> close: () => Promise<boolean>
} }
key: { key: {
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }> autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
@@ -55,6 +56,11 @@ export interface ElectronAPI {
chat: { chat: {
connect: () => Promise<{ success: boolean; error?: string }> connect: () => Promise<{ success: boolean; error?: string }>
getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }> getSessions: () => Promise<{ success: boolean; sessions?: ChatSession[]; error?: string }>
enrichSessionsContactInfo: (usernames: string[]) => Promise<{
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}>
getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{ getMessages: (sessionId: string, offset?: number, limit?: number) => Promise<{
success: boolean; success: boolean;
messages?: Message[]; messages?: Message[];
@@ -90,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 }>
@@ -98,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
@@ -257,12 +264,12 @@ export interface ElectronAPI {
fastestFriend: string fastestFriend: string
fastestTime: number fastestTime: number
} | null } | null
topPhrases: Array<{ topPhrases: Array<{
phrase: string phrase: string
count: number count: number
}> }>
} }
error?: string error?: string
}> }>
exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{ exportImages: (payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => Promise<{
success: boolean success: boolean

View File

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

View File

@@ -10,6 +10,14 @@ export default defineConfig({
port: 3000, 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) {