mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b96a47fe29 | ||
|
|
b7eb19aad6 | ||
|
|
2e41a03c96 | ||
|
|
3151f79ee7 | ||
|
|
e7c93ea2f7 | ||
|
|
f09ab1bbcc | ||
|
|
e6a0726b8d | ||
|
|
cada002587 | ||
|
|
38e87b8cbf | ||
|
|
bd94ba7b1a | ||
|
|
756ee03aa0 | ||
|
|
76aa875085 | ||
|
|
16fa8510e6 | ||
|
|
b587e6bd6f | ||
|
|
13cc3751b5 | ||
|
|
ba65c5f3ad | ||
|
|
cfd7635323 | ||
|
|
895249940c | ||
|
|
6b85d8a5f1 | ||
|
|
5c1773efac | ||
|
|
fa783159ff | ||
|
|
e85254bf98 | ||
|
|
e5f57c7359 | ||
|
|
4cbce8c38f | ||
|
|
d111513346 | ||
|
|
e2d34fc530 | ||
|
|
a1d11e4132 | ||
|
|
ac95c99541 | ||
|
|
654eb40740 | ||
|
|
bd3e9a63b7 | ||
|
|
bc9ef140f5 | ||
|
|
f864189407 | ||
|
|
f321c465d5 |
24
.github/workflows/release.yml
vendored
24
.github/workflows/release.yml
vendored
@@ -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
7
.gitignore
vendored
@@ -13,6 +13,7 @@ dist
|
|||||||
dist-electron
|
dist-electron
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
test/
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
@@ -42,6 +43,10 @@ release
|
|||||||
# OS
|
# OS
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Electron dev cache
|
||||||
|
.electron/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# 忽略 Visual Studio 临时文件夹
|
# 忽略 Visual Studio 临时文件夹
|
||||||
@@ -50,4 +55,4 @@ Thumbs.db
|
|||||||
*.ipch
|
*.ipch
|
||||||
*.aps
|
*.aps
|
||||||
|
|
||||||
wcdb/
|
wcdb/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ function isThumbnailDat(fileName: string): boolean {
|
|||||||
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
return fileName.includes('.t.dat') || fileName.includes('_t.dat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHdDat(fileName: string): boolean {
|
||||||
|
const lower = fileName.toLowerCase()
|
||||||
|
const base = lower.endsWith('.dat') ? lower.slice(0, -4) : lower
|
||||||
|
return base.endsWith('_hd') || base.endsWith('_h')
|
||||||
|
}
|
||||||
|
|
||||||
function walkForDat(
|
function walkForDat(
|
||||||
root: string,
|
root: string,
|
||||||
datName: string,
|
datName: string,
|
||||||
@@ -101,6 +107,8 @@ function walkForDat(
|
|||||||
if (!isLikelyImageDatBase(baseLower)) continue
|
if (!isLikelyImageDatBase(baseLower)) continue
|
||||||
if (!hasXVariant(baseLower)) continue
|
if (!hasXVariant(baseLower)) continue
|
||||||
if (!matchesDatName(lower, datName)) continue
|
if (!matchesDatName(lower, datName)) continue
|
||||||
|
// 排除高清图片格式 (_hd, _h)
|
||||||
|
if (isHdDat(lower)) continue
|
||||||
matchedBases.add(baseLower)
|
matchedBases.add(baseLower)
|
||||||
const isThumb = isThumbnailDat(lower)
|
const isThumb = isThumbnailDat(lower)
|
||||||
if (!allowThumbnail && isThumb) continue
|
if (!allowThumbnail && isThumb) continue
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService'
|
|||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
autoUpdater.autoDownload = false
|
autoUpdater.autoDownload = false
|
||||||
autoUpdater.autoInstallOnAppQuit = true
|
autoUpdater.autoInstallOnAppQuit = true
|
||||||
@@ -381,6 +382,8 @@ function registerIpcHandlers() {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 聊天相关
|
// 聊天相关
|
||||||
ipcMain.handle('chat:connect', async () => {
|
ipcMain.handle('chat:connect', async () => {
|
||||||
return chatService.connect()
|
return chatService.connect()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { readFile, writeFile } from 'fs/promises'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
export interface ChatStatistics {
|
export interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -253,15 +256,31 @@ class AnalyticsService {
|
|||||||
sessionIds: string[],
|
sessionIds: string[],
|
||||||
beginTimestamp = 0,
|
beginTimestamp = 0,
|
||||||
endTimestamp = 0,
|
endTimestamp = 0,
|
||||||
window?: any
|
window?: any,
|
||||||
|
force = false
|
||||||
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
|
): Promise<{ success: boolean; data?: any; source?: string; error?: string }> {
|
||||||
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
|
const cacheKey = this.buildAggregateCacheKey(sessionIds, beginTimestamp, endTimestamp)
|
||||||
if (this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
|
||||||
|
if (force) {
|
||||||
|
if (this.aggregateCache) this.aggregateCache = null
|
||||||
|
if (this.fallbackAggregateCache) this.fallbackAggregateCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && this.aggregateCache && this.aggregateCache.key === cacheKey) {
|
||||||
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
|
if (Date.now() - this.aggregateCache.updatedAt < 5 * 60 * 1000) {
|
||||||
return { success: true, data: this.aggregateCache.data, source: 'cache' }
|
return { success: true, data: this.aggregateCache.data, source: 'cache' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试从文件加载缓存
|
||||||
|
if (!force) {
|
||||||
|
const fileCache = await this.loadCacheFromFile()
|
||||||
|
if (fileCache && fileCache.key === cacheKey) {
|
||||||
|
this.aggregateCache = fileCache
|
||||||
|
return { success: true, data: fileCache.data, source: 'file-cache' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||||
return this.aggregatePromise.promise
|
return this.aggregatePromise.promise
|
||||||
}
|
}
|
||||||
@@ -291,7 +310,12 @@ class AnalyticsService {
|
|||||||
|
|
||||||
this.aggregatePromise = { key: cacheKey, promise }
|
this.aggregatePromise = { key: cacheKey, promise }
|
||||||
try {
|
try {
|
||||||
return await promise
|
const result = await promise
|
||||||
|
// 如果计算成功,同时写入此文件缓存
|
||||||
|
if (result.success && result.data && result.source !== 'cache') {
|
||||||
|
this.saveCacheToFile({ key: cacheKey, data: this.aggregateCache?.data, updatedAt: Date.now() })
|
||||||
|
}
|
||||||
|
return result
|
||||||
} finally {
|
} finally {
|
||||||
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
if (this.aggregatePromise && this.aggregatePromise.key === cacheKey) {
|
||||||
this.aggregatePromise = null
|
this.aggregatePromise = null
|
||||||
@@ -299,6 +323,25 @@ class AnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCacheFilePath(): string {
|
||||||
|
return join(app.getPath('userData'), 'analytics_cache.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCacheFromFile(): Promise<{ key: string; data: any; updatedAt: number } | null> {
|
||||||
|
try {
|
||||||
|
const raw = await readFile(this.getCacheFilePath(), 'utf-8')
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveCacheToFile(data: any) {
|
||||||
|
try {
|
||||||
|
await writeFile(this.getCacheFilePath(), JSON.stringify(data))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存统计缓存失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeAggregateSessions(
|
private normalizeAggregateSessions(
|
||||||
sessions: Record<string, any> | undefined,
|
sessions: Record<string, any> | undefined,
|
||||||
idMap: Record<string, string> | undefined
|
idMap: Record<string, string> | undefined
|
||||||
@@ -326,7 +369,7 @@ class AnalyticsService {
|
|||||||
void results
|
void results
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOverallStatistics(): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
async getOverallStatistics(force = false): Promise<{ success: boolean; data?: ChatStatistics; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
|
||||||
@@ -340,7 +383,7 @@ class AnalyticsService {
|
|||||||
const win = BrowserWindow.getAllWindows()[0]
|
const win = BrowserWindow.getAllWindows()[0]
|
||||||
this.setProgress(win, '正在执行原生数据聚合...', 30)
|
this.setProgress(win, '正在执行原生数据聚合...', 30)
|
||||||
|
|
||||||
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win)
|
const result = await this.getAggregateWithFallback(sessionInfo.usernames, 0, 0, win, force)
|
||||||
|
|
||||||
if (!result.success || !result.data) {
|
if (!result.success || !result.data) {
|
||||||
return { success: false, error: result.error || '聚合统计失败' }
|
return { success: false, error: result.error || '聚合统计失败' }
|
||||||
@@ -458,8 +501,8 @@ class AnalyticsService {
|
|||||||
|
|
||||||
const d = result.data
|
const d = result.data
|
||||||
|
|
||||||
// SQLite strftime('%w') 返回 0=Sun, 1=Mon...6=Sat
|
// SQLite strftime('%w') 返回 0=周日, 1=周一...6=周六
|
||||||
// 前端期望 1=Mon...7=Sun
|
// 前端期望 1=周一...7=周日
|
||||||
const weekdayDistribution: Record<number, number> = {}
|
const weekdayDistribution: Record<number, number> = {}
|
||||||
for (const [w, count] of Object.entries(d.weekday)) {
|
for (const [w, count] of Object.entries(d.weekday)) {
|
||||||
const sqliteW = parseInt(w, 10)
|
const sqliteW = parseInt(w, 10)
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { app } from 'electron'
|
|||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
import { ConfigService } from './config'
|
import { ConfigService } from './config'
|
||||||
import { wcdbService } from './wcdbService'
|
import { wcdbService } from './wcdbService'
|
||||||
|
import { MessageCacheService } from './messageCacheService'
|
||||||
|
import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
|
||||||
|
|
||||||
type HardlinkState = {
|
type HardlinkState = {
|
||||||
db: Database.Database
|
db: Database.Database
|
||||||
@@ -56,6 +58,7 @@ export interface Message {
|
|||||||
aesKey?: string
|
aesKey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
cdnThumbUrl?: string
|
cdnThumbUrl?: string
|
||||||
|
voiceDurationSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
@@ -74,13 +77,19 @@ class ChatService {
|
|||||||
private connected = false
|
private connected = false
|
||||||
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number }> = new Map()
|
||||||
private readonly messageBatchDefault = 50
|
private readonly messageBatchDefault = 50
|
||||||
private avatarCache: Map<string, { avatarUrl?: string; displayName?: string; updatedAt: number }> = new Map()
|
private avatarCache: Map<string, ContactCacheEntry>
|
||||||
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
private readonly avatarCacheTtlMs = 10 * 60 * 1000
|
||||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||||
private hardlinkCache = new Map<string, HardlinkState>()
|
private hardlinkCache = new Map<string, HardlinkState>()
|
||||||
|
private readonly contactCacheService: ContactCacheService
|
||||||
|
private readonly messageCacheService: MessageCacheService
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.configService = new ConfigService()
|
this.configService = new ConfigService()
|
||||||
|
this.contactCacheService = new ContactCacheService(this.configService.get('cachePath'))
|
||||||
|
const persisted = this.contactCacheService.getAllEntries()
|
||||||
|
this.avatarCache = new Map(Object.entries(persisted))
|
||||||
|
this.messageCacheService = new MessageCacheService(this.configService.get('cachePath'))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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_type(0=视频,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface ConfigSchema {
|
|||||||
themeId: string
|
themeId: string
|
||||||
language: string
|
language: string
|
||||||
logEnabled: boolean
|
logEnabled: boolean
|
||||||
|
llmModelPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ConfigService {
|
export class ConfigService {
|
||||||
@@ -40,7 +41,8 @@ export class ConfigService {
|
|||||||
theme: 'system',
|
theme: 'system',
|
||||||
themeId: 'cloud-dancer',
|
themeId: 'cloud-dancer',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
logEnabled: false
|
logEnabled: false,
|
||||||
|
llmModelPath: ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
75
electron/services/contactCacheService.ts
Normal file
75
electron/services/contactCacheService.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export interface ContactCacheEntry {
|
||||||
|
displayName?: string
|
||||||
|
avatarUrl?: string
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ContactCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private cache: Record<string, ContactCacheEntry> = {}
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: join(app.getPath('userData'), 'WeFlowCache')
|
||||||
|
this.cacheFilePath = join(basePath, 'contacts.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.loadCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir() {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCache() {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.cache = parsed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 载入缓存失败', error)
|
||||||
|
this.cache = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(username: string): ContactCacheEntry | undefined {
|
||||||
|
return this.cache[username]
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllEntries(): Record<string, ContactCacheEntry> {
|
||||||
|
return { ...this.cache }
|
||||||
|
}
|
||||||
|
|
||||||
|
setEntries(entries: Record<string, ContactCacheEntry>): void {
|
||||||
|
if (Object.keys(entries).length === 0) return
|
||||||
|
let changed = false
|
||||||
|
for (const [username, entry] of Object.entries(entries)) {
|
||||||
|
const existing = this.cache[username]
|
||||||
|
if (!existing || entry.updatedAt >= existing.updatedAt) {
|
||||||
|
this.cache[username] = entry
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ContactCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ interface ChatLabMeta {
|
|||||||
platform: string
|
platform: string
|
||||||
type: 'group' | 'private'
|
type: 'group' | 'private'
|
||||||
groupId?: string
|
groupId?: string
|
||||||
|
groupAvatar?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatLabMember {
|
interface ChatLabMember {
|
||||||
@@ -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_type(0=视频,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ class GroupAnalyticsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
|
async getGroupActiveHours(chatroomId: string, startTime?: number, endTime?: number): Promise<{ success: boolean; data?: GroupActiveHours; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const conn = await this.ensureConnected()
|
const conn = await this.ensureConnected()
|
||||||
|
|||||||
@@ -1,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
68
electron/services/messageCacheService.ts
Normal file
68
electron/services/messageCacheService.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||||
|
import { app } from 'electron'
|
||||||
|
|
||||||
|
export interface SessionMessageCacheEntry {
|
||||||
|
updatedAt: number
|
||||||
|
messages: any[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MessageCacheService {
|
||||||
|
private readonly cacheFilePath: string
|
||||||
|
private cache: Record<string, SessionMessageCacheEntry> = {}
|
||||||
|
private readonly sessionLimit = 150
|
||||||
|
|
||||||
|
constructor(cacheBasePath?: string) {
|
||||||
|
const basePath = cacheBasePath && cacheBasePath.trim().length > 0
|
||||||
|
? cacheBasePath
|
||||||
|
: join(app.getPath('userData'), 'WeFlowCache')
|
||||||
|
this.cacheFilePath = join(basePath, 'session-messages.json')
|
||||||
|
this.ensureCacheDir()
|
||||||
|
this.loadCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureCacheDir() {
|
||||||
|
const dir = dirname(this.cacheFilePath)
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCache() {
|
||||||
|
if (!existsSync(this.cacheFilePath)) return
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(this.cacheFilePath, 'utf8')
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (parsed && typeof parsed === 'object') {
|
||||||
|
this.cache = parsed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 载入缓存失败', error)
|
||||||
|
this.cache = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(sessionId: string): SessionMessageCacheEntry | undefined {
|
||||||
|
return this.cache[sessionId]
|
||||||
|
}
|
||||||
|
|
||||||
|
set(sessionId: string, messages: any[]): void {
|
||||||
|
if (!sessionId) return
|
||||||
|
const trimmed = messages.length > this.sessionLimit
|
||||||
|
? messages.slice(-this.sessionLimit)
|
||||||
|
: messages.slice()
|
||||||
|
this.cache[sessionId] = {
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
messages: trimmed
|
||||||
|
}
|
||||||
|
this.persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private persist() {
|
||||||
|
try {
|
||||||
|
writeFileSync(this.cacheFilePath, JSON.stringify(this.cache), 'utf8')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MessageCacheService: 保存缓存失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1317
electron/services/wcdbCore.ts
Normal file
1317
electron/services/wcdbCore.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
122
electron/wcdbWorker.ts
Normal file
122
electron/wcdbWorker.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { parentPort, workerData } from 'worker_threads'
|
||||||
|
import { WcdbCore } from './services/wcdbCore'
|
||||||
|
|
||||||
|
const core = new WcdbCore()
|
||||||
|
|
||||||
|
if (parentPort) {
|
||||||
|
parentPort.on('message', async (msg) => {
|
||||||
|
const { id, type, payload } = msg
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: any
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'setPaths':
|
||||||
|
core.setPaths(payload.resourcesPath, payload.userDataPath)
|
||||||
|
result = { success: true }
|
||||||
|
break
|
||||||
|
case 'setLogEnabled':
|
||||||
|
core.setLogEnabled(payload.enabled)
|
||||||
|
result = { success: true }
|
||||||
|
break
|
||||||
|
case 'testConnection':
|
||||||
|
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
|
break
|
||||||
|
case 'open':
|
||||||
|
result = await core.open(payload.dbPath, payload.hexKey, payload.wxid)
|
||||||
|
break
|
||||||
|
case 'close':
|
||||||
|
core.close()
|
||||||
|
result = { success: true }
|
||||||
|
break
|
||||||
|
case 'isConnected':
|
||||||
|
result = core.isConnected()
|
||||||
|
break
|
||||||
|
case 'getSessions':
|
||||||
|
result = await core.getSessions()
|
||||||
|
break
|
||||||
|
case 'getMessages':
|
||||||
|
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
|
||||||
|
break
|
||||||
|
case 'getMessageCount':
|
||||||
|
result = await core.getMessageCount(payload.sessionId)
|
||||||
|
break
|
||||||
|
case 'getDisplayNames':
|
||||||
|
result = await core.getDisplayNames(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getAvatarUrls':
|
||||||
|
result = await core.getAvatarUrls(payload.usernames)
|
||||||
|
break
|
||||||
|
case 'getGroupMemberCount':
|
||||||
|
result = await core.getGroupMemberCount(payload.chatroomId)
|
||||||
|
break
|
||||||
|
case 'getGroupMemberCounts':
|
||||||
|
result = await core.getGroupMemberCounts(payload.chatroomIds)
|
||||||
|
break
|
||||||
|
case 'getGroupMembers':
|
||||||
|
result = await core.getGroupMembers(payload.chatroomId)
|
||||||
|
break
|
||||||
|
case 'getMessageTables':
|
||||||
|
result = await core.getMessageTables(payload.sessionId)
|
||||||
|
break
|
||||||
|
case 'getMessageTableStats':
|
||||||
|
result = await core.getMessageTableStats(payload.sessionId)
|
||||||
|
break
|
||||||
|
case 'getMessageMeta':
|
||||||
|
result = await core.getMessageMeta(payload.dbPath, payload.tableName, payload.limit, payload.offset)
|
||||||
|
break
|
||||||
|
case 'getContact':
|
||||||
|
result = await core.getContact(payload.username)
|
||||||
|
break
|
||||||
|
case 'getAggregateStats':
|
||||||
|
result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'getAvailableYears':
|
||||||
|
result = await core.getAvailableYears(payload.sessionIds)
|
||||||
|
break
|
||||||
|
case 'getAnnualReportStats':
|
||||||
|
result = await core.getAnnualReportStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'getAnnualReportExtras':
|
||||||
|
result = await core.getAnnualReportExtras(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp, payload.peakDayBegin, payload.peakDayEnd)
|
||||||
|
break
|
||||||
|
case 'getGroupStats':
|
||||||
|
result = await core.getGroupStats(payload.chatroomId, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'openMessageCursor':
|
||||||
|
result = await core.openMessageCursor(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'openMessageCursorLite':
|
||||||
|
result = await core.openMessageCursorLite(payload.sessionId, payload.batchSize, payload.ascending, payload.beginTimestamp, payload.endTimestamp)
|
||||||
|
break
|
||||||
|
case 'fetchMessageBatch':
|
||||||
|
result = await core.fetchMessageBatch(payload.cursor)
|
||||||
|
break
|
||||||
|
case 'closeMessageCursor':
|
||||||
|
result = await core.closeMessageCursor(payload.cursor)
|
||||||
|
break
|
||||||
|
case 'execQuery':
|
||||||
|
result = await core.execQuery(payload.kind, payload.path, payload.sql)
|
||||||
|
break
|
||||||
|
case 'getEmoticonCdnUrl':
|
||||||
|
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)
|
||||||
|
break
|
||||||
|
case 'listMessageDbs':
|
||||||
|
result = await core.listMessageDbs()
|
||||||
|
break
|
||||||
|
case 'listMediaDbs':
|
||||||
|
result = await core.listMediaDbs()
|
||||||
|
break
|
||||||
|
case 'getMessageById':
|
||||||
|
result = await core.getMessageById(payload.sessionId, payload.localId)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
result = { success: false, error: `Unknown method: ${type}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
parentPort!.postMessage({ id, result })
|
||||||
|
} catch (e) {
|
||||||
|
parentPort!.postMessage({ id, error: String(e) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "weflow",
|
"name": "weflow",
|
||||||
"version": "1.0.0",
|
"version": "1.0.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.5.0",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -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.
@@ -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 />} />
|
||||||
|
|||||||
79
src/components/Avatar.scss
Normal file
79
src/components/Avatar.scss
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
.avatar-component {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-tertiary, #f5f5f5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
/* Default radius */
|
||||||
|
|
||||||
|
&.circle {
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rounded {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image styling */
|
||||||
|
img.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
&.loaded {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.instant {
|
||||||
|
transition: none !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Placeholder/Letter styling */
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
background-color: var(--bg-tertiary, #e0e0e0);
|
||||||
|
font-size: 1.2em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Skeleton */
|
||||||
|
.avatar-skeleton {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
var(--bg-tertiary, #f0f0f0) 25%,
|
||||||
|
var(--bg-secondary, #e0e0e0) 50%,
|
||||||
|
var(--bg-tertiary, #f0f0f0) 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
129
src/components/Avatar.tsx
Normal file
129
src/components/Avatar.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { User } from 'lucide-react'
|
||||||
|
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
|
||||||
|
import './Avatar.scss'
|
||||||
|
|
||||||
|
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
|
||||||
|
const loadedAvatarCache = new Set<string>()
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
src?: string
|
||||||
|
name?: string
|
||||||
|
size?: number | string
|
||||||
|
shape?: 'circle' | 'square' | 'rounded'
|
||||||
|
className?: string
|
||||||
|
lazy?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Avatar = React.memo(function Avatar({
|
||||||
|
src,
|
||||||
|
name,
|
||||||
|
size = 48,
|
||||||
|
shape = 'rounded',
|
||||||
|
className = '',
|
||||||
|
lazy = true,
|
||||||
|
onClick
|
||||||
|
}: AvatarProps) {
|
||||||
|
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
|
||||||
|
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(isCached)
|
||||||
|
const [imageError, setImageError] = useState(false)
|
||||||
|
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
|
||||||
|
const [isInQueue, setIsInQueue] = useState(false)
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const getAvatarLetter = (): string => {
|
||||||
|
if (!name) return '?'
|
||||||
|
const chars = [...name]
|
||||||
|
return chars[0] || '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intersection Observer for lazy loading
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached) return
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !isInQueue) {
|
||||||
|
setIsInQueue(true)
|
||||||
|
avatarLoadQueue.enqueue(src).then(() => {
|
||||||
|
setShouldLoad(true)
|
||||||
|
}).catch(() => {
|
||||||
|
// 加载失败不要立刻显示错误,让浏览器渲染去报错
|
||||||
|
setShouldLoad(true)
|
||||||
|
}).finally(() => {
|
||||||
|
setIsInQueue(false)
|
||||||
|
})
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ rootMargin: '100px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(containerRef.current)
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [src, lazy, shouldLoad, isInQueue, isCached])
|
||||||
|
|
||||||
|
// Reset state when src changes
|
||||||
|
useEffect(() => {
|
||||||
|
const cached = src ? loadedAvatarCache.has(src) : false
|
||||||
|
setImageLoaded(cached)
|
||||||
|
setImageError(false)
|
||||||
|
if (lazy && !cached) {
|
||||||
|
setShouldLoad(false)
|
||||||
|
setIsInQueue(false)
|
||||||
|
} else {
|
||||||
|
setShouldLoad(true)
|
||||||
|
}
|
||||||
|
}, [src, lazy])
|
||||||
|
|
||||||
|
// Check if image is already cached/loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||||
|
setImageLoaded(true)
|
||||||
|
}
|
||||||
|
}, [src, shouldLoad])
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
width: typeof size === 'number' ? `${size}px` : size,
|
||||||
|
height: typeof size === 'number' ? `${size}px` : size,
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasValidUrl = !!src && !imageError && shouldLoad
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`avatar-component ${shape} ${className}`}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{hasValidUrl ? (
|
||||||
|
<>
|
||||||
|
{!imageLoaded && <div className="avatar-skeleton" />}
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={src}
|
||||||
|
alt={name || 'avatar'}
|
||||||
|
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
|
||||||
|
onLoad={() => {
|
||||||
|
if (src) loadedAvatarCache.add(src)
|
||||||
|
setImageLoaded(true)
|
||||||
|
}}
|
||||||
|
onError={() => setImageError(true)}
|
||||||
|
loading={lazy ? "lazy" : "eager"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="avatar-placeholder">
|
||||||
|
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download } from 'lucide-react'
|
import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot } from 'lucide-react'
|
||||||
import './Sidebar.scss'
|
import './Sidebar.scss'
|
||||||
|
|
||||||
function Sidebar() {
|
function Sidebar() {
|
||||||
@@ -34,6 +34,8 @@ function Sidebar() {
|
|||||||
<span className="nav-label">聊天</span>
|
<span className="nav-label">聊天</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 私聊分析 */}
|
{/* 私聊分析 */}
|
||||||
<NavLink
|
<NavLink
|
||||||
to="/analytics"
|
to="/analytics"
|
||||||
@@ -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 ? '展开菜单' : '收起菜单'}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
import { Users, Clock, MessageSquare, Send, Inbox, Calendar, Loader2, RefreshCw, User, Medal } from 'lucide-react'
|
||||||
import ReactECharts from 'echarts-for-react'
|
import ReactECharts from 'echarts-for-react'
|
||||||
import { useAnalyticsStore } from '../stores/analyticsStore'
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnalyticsPage.scss'
|
import './AnalyticsPage.scss'
|
||||||
import './DataManagementPage.scss'
|
import './DataManagementPage.scss'
|
||||||
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
@@ -28,7 +30,7 @@ function AnalyticsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingStatus('正在统计消息数据...')
|
setLoadingStatus('正在统计消息数据...')
|
||||||
const statsResult = await window.electronAPI.analytics.getOverallStatistics()
|
const statsResult = await window.electronAPI.analytics.getOverallStatistics(forceRefresh)
|
||||||
if (statsResult.success && statsResult.data) {
|
if (statsResult.success && statsResult.data) {
|
||||||
setStatistics(statsResult.data)
|
setStatistics(statsResult.data)
|
||||||
} else {
|
} else {
|
||||||
@@ -55,7 +57,12 @@ function AnalyticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { loadData() }, [])
|
const location = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const force = location.state?.forceRefresh === true
|
||||||
|
loadData(force)
|
||||||
|
}, [location.state])
|
||||||
|
|
||||||
const handleRefresh = () => loadData(true)
|
const handleRefresh = () => loadData(true)
|
||||||
|
|
||||||
@@ -289,7 +296,7 @@ function AnalyticsPage() {
|
|||||||
<div key={contact.username} className="ranking-item">
|
<div key={contact.username} className="ranking-item">
|
||||||
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
<span className={`rank ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
|
||||||
<div className="contact-avatar">
|
<div className="contact-avatar">
|
||||||
{contact.avatarUrl ? <img src={contact.avatarUrl} alt="" /> : <div className="avatar-placeholder"><User size={20} /></div>}
|
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} />
|
||||||
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
{index < 3 && <div className={`medal medal-${index + 1}`}><Medal size={10} /></div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="contact-info">
|
<div className="contact-info">
|
||||||
|
|||||||
119
src/pages/AnalyticsWelcomePage.scss
Normal file
119
src/pages/AnalyticsWelcomePage.scss
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.analytics-welcome-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
animation: fadeIn 0.4s ease-out;
|
||||||
|
|
||||||
|
.welcome-content {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 600px;
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
background: rgba(7, 193, 96, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #07c160;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 30px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #07c160;
|
||||||
|
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.1);
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
color: #07c160;
|
||||||
|
background: rgba(7, 193, 96, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
filter: grayscale(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
63
src/pages/AnalyticsWelcomePage.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { BarChart2, History, RefreshCcw } from 'lucide-react'
|
||||||
|
import { useAnalyticsStore } from '../stores/analyticsStore'
|
||||||
|
import './AnalyticsWelcomePage.scss'
|
||||||
|
|
||||||
|
function AnalyticsWelcomePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
// 检查是否有任何缓存数据加载或基本的存储状态表明它已准备好。
|
||||||
|
// 实际上,如果 store 没有持久化,`isLoaded` 可能会在应用刷新时重置。
|
||||||
|
// 如果用户点击“加载缓存”但缓存为空,AnalyticsPage 的逻辑(loadData 不带 force)将尝试从后端缓存加载。
|
||||||
|
// 如果后端缓存也为空,则会重新计算。
|
||||||
|
|
||||||
|
// 我们也可以检查 `lastLoadTime` 来显示“上次更新:xxx”(如果已持久化)。
|
||||||
|
const { lastLoadTime } = useAnalyticsStore()
|
||||||
|
|
||||||
|
const handleLoadCache = () => {
|
||||||
|
navigate('/analytics/view')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNewAnalysis = () => {
|
||||||
|
navigate('/analytics/view', { state: { forceRefresh: true } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLastTime = (ts: number | null) => {
|
||||||
|
if (!ts) return '无记录'
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="analytics-welcome-container">
|
||||||
|
<div className="welcome-content">
|
||||||
|
<div className="icon-wrapper">
|
||||||
|
<BarChart2 size={40} />
|
||||||
|
</div>
|
||||||
|
<h1>私聊数据分析</h1>
|
||||||
|
<p>
|
||||||
|
WeFlow 可以分析您的聊天记录,生成详细的统计报表。<br />
|
||||||
|
您可以选择加载上次的分析结果(速度快),或者开始新的分析(数据最新)。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="action-cards">
|
||||||
|
<button onClick={handleLoadCache}>
|
||||||
|
<div className="card-icon">
|
||||||
|
<History size={24} />
|
||||||
|
</div>
|
||||||
|
<h3>加载缓存</h3>
|
||||||
|
<span>查看上次分析结果<br />(上次更新: {formatLastTime(lastLoadTime)})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={handleNewAnalysis}>
|
||||||
|
<div className="card-icon">
|
||||||
|
<RefreshCcw size={24} />
|
||||||
|
</div>
|
||||||
|
<h3>新的分析</h3>
|
||||||
|
<span>重新扫描并计算数据<br />(可能需要几分钟)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AnalyticsWelcomePage
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
|
|
||||||
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
{dbKeyStatus && <div className="field-hint status-text">{dbKeyStatus}</div>}
|
||||||
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
<div className="field-hint">获取密钥会自动识别最近登录的账号</div>
|
||||||
|
<div className="field-hint">点击自动获取后微信将重新启动,当页面提示可以登录微信了再点击登录</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -532,6 +533,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
|
|||||||
</button>
|
</button>
|
||||||
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
{imageKeyStatus && <div className="field-hint status-text">{imageKeyStatus}</div>}
|
||||||
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
<div className="field-hint">如获取失败,请先打开朋友圈图片再重试</div>
|
||||||
|
{isFetchingImageKey && <div className="field-hint status-text">正在扫描内存,请稍候...</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const CONFIG_KEYS = {
|
|||||||
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
AGREEMENT_ACCEPTED: 'agreementAccepted',
|
||||||
LOG_ENABLED: 'logEnabled',
|
LOG_ENABLED: 'logEnabled',
|
||||||
ONBOARDING_DONE: 'onboardingDone',
|
ONBOARDING_DONE: 'onboardingDone',
|
||||||
|
LLM_MODEL_PATH: 'llmModelPath',
|
||||||
IMAGE_XOR_KEY: 'imageXorKey',
|
IMAGE_XOR_KEY: 'imageXorKey',
|
||||||
IMAGE_AES_KEY: 'imageAesKey'
|
IMAGE_AES_KEY: 'imageAesKey'
|
||||||
} as const
|
} as const
|
||||||
@@ -132,6 +133,17 @@ export async function setLogEnabled(enabled: boolean): Promise<void> {
|
|||||||
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
await config.set(CONFIG_KEYS.LOG_ENABLED, enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取 LLM 模型路径
|
||||||
|
export async function getLlmModelPath(): Promise<string | null> {
|
||||||
|
const value = await config.get(CONFIG_KEYS.LLM_MODEL_PATH)
|
||||||
|
return (value as string) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 LLM 模型路径
|
||||||
|
export async function setLlmModelPath(path: string): Promise<void> {
|
||||||
|
await config.set(CONFIG_KEYS.LLM_MODEL_PATH, path)
|
||||||
|
}
|
||||||
|
|
||||||
// 清除所有配置
|
// 清除所有配置
|
||||||
export async function clearConfig(): Promise<void> {
|
export async function clearConfig(): Promise<void> {
|
||||||
await config.clear()
|
await config.clear()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
interface ChatStatistics {
|
interface ChatStatistics {
|
||||||
totalMessages: number
|
totalMessages: number
|
||||||
@@ -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',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
21
src/types/electron.d.ts
vendored
21
src/types/electron.d.ts
vendored
@@ -45,6 +45,7 @@ export interface ElectronAPI {
|
|||||||
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
|
testConnection: (dbPath: string, hexKey: string, wxid: string) => Promise<{ success: boolean; error?: string; sessionCount?: number }>
|
||||||
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
|
open: (dbPath: string, hexKey: string, wxid: string) => Promise<boolean>
|
||||||
close: () => Promise<boolean>
|
close: () => Promise<boolean>
|
||||||
|
|
||||||
}
|
}
|
||||||
key: {
|
key: {
|
||||||
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
autoGetDbKey: () => Promise<{ success: boolean; key?: string; error?: string; logs?: string[] }>
|
||||||
@@ -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
|
||||||
|
|||||||
74
src/utils/AvatarLoadQueue.ts
Normal file
74
src/utils/AvatarLoadQueue.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
|
||||||
|
// 全局头像加载队列管理器(限制并发,避免卡顿)
|
||||||
|
export class AvatarLoadQueue {
|
||||||
|
private queue: Array<{ url: string; resolve: () => void; reject: (error: Error) => void }> = []
|
||||||
|
private loading = new Map<string, Promise<void>>()
|
||||||
|
private activeCount = 0
|
||||||
|
private readonly maxConcurrent = 3
|
||||||
|
private readonly delayBetweenBatches = 10
|
||||||
|
|
||||||
|
private static instance: AvatarLoadQueue
|
||||||
|
|
||||||
|
public static getInstance(): AvatarLoadQueue {
|
||||||
|
if (!AvatarLoadQueue.instance) {
|
||||||
|
AvatarLoadQueue.instance = new AvatarLoadQueue()
|
||||||
|
}
|
||||||
|
return AvatarLoadQueue.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
async enqueue(url: string): Promise<void> {
|
||||||
|
if (!url) return Promise.resolve()
|
||||||
|
|
||||||
|
// 核心修复:防止重复并发请求同一个 URL
|
||||||
|
const existingPromise = this.loading.get(url)
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||||
|
this.queue.push({ url, resolve, reject })
|
||||||
|
this.processQueue()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.loading.set(url, loadPromise)
|
||||||
|
loadPromise.finally(() => {
|
||||||
|
this.loading.delete(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue() {
|
||||||
|
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = this.queue.shift()
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
this.activeCount++
|
||||||
|
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
this.activeCount--
|
||||||
|
task.resolve()
|
||||||
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
this.activeCount--
|
||||||
|
task.reject(new Error(`Failed: ${task.url}`))
|
||||||
|
setTimeout(() => this.processQueue(), this.delayBetweenBatches)
|
||||||
|
}
|
||||||
|
img.src = task.url
|
||||||
|
|
||||||
|
this.processQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.queue = []
|
||||||
|
this.loading.clear()
|
||||||
|
this.activeCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const avatarLoadQueue = AvatarLoadQueue.getInstance()
|
||||||
@@ -10,6 +10,14 @@ export default defineConfig({
|
|||||||
port: 3000,
|
port: 3000,
|
||||||
strictPort: false // 如果3000被占用,自动尝试下一个
|
strictPort: false // 如果3000被占用,自动尝试下一个
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
commonjsOptions: {
|
||||||
|
ignoreDynamicRequires: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: []
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
electron([
|
electron([
|
||||||
@@ -19,7 +27,11 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist-electron',
|
outDir: 'dist-electron',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['better-sqlite3', 'koffi']
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +42,10 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist-electron',
|
outDir: 'dist-electron',
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['koffi'],
|
external: [
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
],
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'annualReportWorker.js',
|
entryFileNames: 'annualReportWorker.js',
|
||||||
inlineDynamicImports: true
|
inlineDynamicImports: true
|
||||||
@@ -53,6 +68,25 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
entry: 'electron/wcdbWorker.ts',
|
||||||
|
vite: {
|
||||||
|
build: {
|
||||||
|
outDir: 'dist-electron',
|
||||||
|
rollupOptions: {
|
||||||
|
external: [
|
||||||
|
'better-sqlite3',
|
||||||
|
'koffi',
|
||||||
|
'fsevents'
|
||||||
|
],
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'wcdbWorker.js',
|
||||||
|
inlineDynamicImports: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
entry: 'electron/preload.ts',
|
entry: 'electron/preload.ts',
|
||||||
onstart(options) {
|
onstart(options) {
|
||||||
|
|||||||
Reference in New Issue
Block a user