Compare commits

...

67 Commits

Author SHA1 Message Date
xuncha
c9216aabad 视频解密优化 2026-02-02 22:59:30 +08:00
xuncha
79d6aef480 同步了密语的头像处理 2026-02-02 22:59:30 +08:00
xuncha
8134d62056 增加对xml的处理 2026-02-02 22:59:30 +08:00
cc
8664ebf6f5 feat: 宇宙超级无敌牛且帅气到爆炸的功能更新和优化 2026-02-02 22:59:30 +08:00
xuncha
7b832ac2ef 给密语的图片查看器搬过来了 2026-02-02 22:59:30 +08:00
xuncha
5934fc33ce 从密语同步了一下图片解密 2026-02-02 22:59:30 +08:00
cc
b6d10f79de feat: 超级无敌帅气的更新和修复 2026-02-02 22:59:30 +08:00
cc
f90822694f feat: 一些非常帅气的优化 2026-02-02 22:59:30 +08:00
cc
123a088a39 feat: 支持忽略更新 2026-02-02 22:59:30 +08:00
xuncha
cb37f534ac Merge pull request #163 from xunchahaha:main
Main
2026-02-01 17:04:06 +08:00
xuncha
50903b35cf 11 2026-02-01 17:03:47 +08:00
xuncha
c07ef66324 Merge pull request #162 from hicccc77/dev
Dev
2026-02-01 16:57:08 +08:00
xuncha
6bc802e77b Merge pull request #161 from xunchahaha/dev
优化html导出
2026-02-01 16:56:46 +08:00
xuncha
898c86c23f 优化html导出 2026-02-01 16:55:01 +08:00
xuncha
7612353389 Merge pull request #160 from xunchahaha:dev
Dev
2026-02-01 15:25:13 +08:00
xuncha
8b37f20b0f 群聊分析 群成员查看修复 2026-02-01 15:24:48 +08:00
cc
0054509ef2 fix: 修复了一个问题 2026-02-01 15:09:40 +08:00
cc
e0f22f58c8 feat: 一些更新 2026-02-01 15:01:50 +08:00
xuncha
6f41cb34ed Merge pull request #159 from xunchahaha:dev
Dev
2026-02-01 02:26:34 +08:00
xuncha
ddbb0c3b26 优化ui 2026-02-01 02:26:00 +08:00
xuncha
f40f885af3 同步ui 2026-02-01 01:26:43 +08:00
xuncha
5413d7e2c8 双人年度报告后端实现 2026-02-01 01:13:17 +08:00
xuncha
53f0e299e0 年度报告ui实现 2026-02-01 00:30:54 +08:00
xuncha
65365107f5 修复群昵称读取错误的问题 2026-02-01 00:07:38 +08:00
xuncha
cffeeb26ec 新增排除好友 2026-01-31 23:44:16 +08:00
xuncha
26d4751e80 Merge pull request #157 from hicccc77/dev
Dev
2026-01-31 18:37:19 +08:00
xuncha
b8120a5119 Merge pull request #156 from xunchahaha/dev
111
2026-01-31 18:36:50 +08:00
xuncha
68a13cefc3 111 2026-01-31 18:36:28 +08:00
xuncha
cd4b8f3702 Merge pull request #155 from hicccc77/main
同步一下
2026-01-31 18:16:11 +08:00
xuncha
c5956ba203 Merge pull request #154 from xunchahaha/main
xiufu
2026-01-31 18:15:10 +08:00
xuncha
f456357e01 Merge pull request #153 from xunchahaha/dev
Dev
2026-01-31 18:14:26 +08:00
xuncha
4ef821f45f 更新版本号 2026-01-31 18:12:57 +08:00
xuncha
912c78e9e9 Merge branch 'main' of https://github.com/xunchahaha/WeFlow 2026-01-31 18:11:58 +08:00
xuncha
bfcd154a25 wxid可以自己选择 2026-01-31 18:11:55 +08:00
xuncha
a1c8ba48b0 Merge pull request #1 from xunchahaha/main
11
2026-01-31 17:46:20 +08:00
xuncha
f93369489d Merge branch 'hicccc77:main' into main 2026-01-31 17:45:54 +08:00
xuncha
014f57f152 尝试修复秘钥获取失败 2026-01-31 17:44:52 +08:00
xuncha
3f1eb58af4 Merge pull request #151 from xunchahaha:main
Main
2026-01-31 16:14:26 +08:00
xuncha
97f0077e95 打包你快修好啊 我服了 2026-01-31 16:14:00 +08:00
xuncha
3d9b1b0f8c Merge pull request #150 from xunchahaha:main
Main
2026-01-31 16:06:54 +08:00
xuncha
cf292ca9e2 hh 2026-01-31 16:06:36 +08:00
xuncha
97f14030de Merge pull request #149 from xunchahaha:main
Main
2026-01-31 16:02:01 +08:00
xuncha
2cfe0d8ee8 ee 2026-01-31 16:01:21 +08:00
xuncha
a760f45823 Merge pull request #148 from xunchahaha:main
Main
2026-01-31 15:56:53 +08:00
xuncha
baa949a301 呃呃 2026-01-31 15:56:27 +08:00
xuncha
c29bbab25f Merge pull request #147 from xunchahaha:main
Main
2026-01-31 15:51:21 +08:00
xuncha
29981e1232 打包优化 2026-01-31 15:51:04 +08:00
xuncha
2d043cd929 Merge pull request #146 from hicccc77/dev
Dev
2026-01-31 15:41:37 +08:00
xuncha
d6dca0e5f7 Merge pull request #145 from xunchahaha:dev
Dev
2026-01-31 15:40:39 +08:00
xuncha
d47166e6f9 修复打包错误 2026-01-31 15:39:59 +08:00
xuncha
6e3bb9e361 图片解密策略更加激进 2026-01-31 15:24:21 +08:00
xuncha
b8dbc3caf1 群聊分析ui调整 2026-01-31 15:04:54 +08:00
xuncha
c1145c8f89 导出群成员第二版 2026-01-31 14:58:15 +08:00
xuncha
0cba8e6d89 导出群成员第一版 2026-01-31 14:26:13 +08:00
xuncha
f6f468dff3 Merge pull request #144 from xunchahaha/dev
Dev
2026-01-31 14:01:22 +08:00
xuncha
04fc5f9104 修复切换账号后的异常问题 2026-01-31 14:00:01 +08:00
xuncha
3c9ab6763c 导出方面再优化 媒体并行导出 2026-01-31 13:49:21 +08:00
cc
f360333ab4 Merge pull request #143 from hicccc77/dev
Dev
2026-01-30 23:49:43 +08:00
cc
834aa6eecb Merge branch 'main' into dev 2026-01-30 23:49:33 +08:00
cc
2400cc8b55 Merge pull request #142 from yunxilyf/main
fix:自动保存bug
2026-01-30 23:48:39 +08:00
cc
e4ed7faca9 feat: 一些优化 2026-01-30 23:47:46 +08:00
yunxilyf
8012aa49ee fix:自动保存bug 2026-01-30 23:46:26 +08:00
xuncha
7225358b91 Merge pull request #140 from xunchahaha/dev
Dev
2026-01-30 20:47:01 +08:00
xuncha
39688e8e0c Merge branch 'hicccc77:dev' into dev 2026-01-30 20:46:47 +08:00
xuncha
592ca6128f 导出方面优化 2026-01-30 20:46:02 +08:00
xuncha
7cd27d8905 Merge pull request #139 from xunchahaha/dev
修复自动保存失效
2026-01-30 20:19:42 +08:00
xuncha
bca387c54b 修复自动保存失效 2026-01-30 20:19:23 +08:00
76 changed files with 7688 additions and 1411 deletions

View File

@@ -21,7 +21,7 @@ jobs:
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 22.12
cache: 'npm' cache: 'npm'
- name: Install Dependencies - name: Install Dependencies

1
.gitignore vendored
View File

@@ -57,3 +57,4 @@ Thumbs.db
wcdb/ wcdb/
*info *info
*.md

View File

@@ -0,0 +1,45 @@
import { parentPort, workerData } from 'worker_threads'
import { wcdbService } from './services/wcdbService'
import { dualReportService } from './services/dualReportService'
interface WorkerConfig {
year: number
friendUsername: string
dbPath: string
decryptKey: string
myWxid: string
resourcesPath?: string
userDataPath?: string
logEnabled?: boolean
}
const config = workerData as WorkerConfig
process.env.WEFLOW_WORKER = '1'
if (config.resourcesPath) {
process.env.WCDB_RESOURCES_PATH = config.resourcesPath
}
wcdbService.setPaths(config.resourcesPath || '', config.userDataPath || '')
wcdbService.setLogEnabled(config.logEnabled === true)
async function run() {
const result = await dualReportService.generateReportWithConfig({
year: config.year,
friendUsername: config.friendUsername,
dbPath: config.dbPath,
decryptKey: config.decryptKey,
wxid: config.myWxid,
onProgress: (status: string, progress: number) => {
parentPort?.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
})
parentPort?.postMessage({ type: 'dualReport:result', data: result })
}
run().catch((err) => {
parentPort?.postMessage({ type: 'dualReport:error', error: String(err) })
})

View File

@@ -20,6 +20,8 @@ import { voiceTranscribeService } from './services/voiceTranscribeService'
import { videoService } from './services/videoService' import { videoService } from './services/videoService'
import { snsService } from './services/snsService' import { snsService } from './services/snsService'
import { contactExportService } from './services/contactExportService' import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
// 配置自动更新 // 配置自动更新
@@ -138,6 +140,14 @@ function createWindow(options: { autoShow?: boolean } = {}) {
win.loadFile(join(__dirname, '../dist/index.html')) win.loadFile(join(__dirname, '../dist/index.html'))
} }
// Handle notification click navigation
ipcMain.on('notification-clicked', (_, sessionId) => {
if (win.isMinimized()) win.restore()
win.show()
win.focus()
win.webContents.send('navigate-to-session', sessionId)
})
// 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权 // 拦截请求,修改 Referer 和 User-Agent 以通过微信 CDN 鉴权
session.defaultSession.webRequest.onBeforeSendHeaders( session.defaultSession.webRequest.onBeforeSendHeaders(
{ {
@@ -365,6 +375,64 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
hash: `/video-player-window?${videoParam}` hash: `/video-player-window?${videoParam}`
}) })
} }
}
/**
* 创建独立的图片查看窗口
*/
function createImageViewerWindow(imagePath: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
const win = new BrowserWindow({
width: 900,
height: 700,
minWidth: 400,
minHeight: 300,
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
webSecurity: false // 允许加载本地文件
},
titleBarStyle: 'hidden',
titleBarOverlay: {
color: '#00000000',
symbolColor: '#ffffff',
height: 40
},
show: false,
backgroundColor: '#000000',
autoHideMenuBar: true
})
win.once('ready-to-show', () => {
win.show()
})
const imageParam = `imagePath=${encodeURIComponent(imagePath)}`
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
win.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
if (win.webContents.isDevToolsOpened()) {
win.webContents.closeDevTools()
} else {
win.webContents.openDevTools()
}
event.preventDefault()
}
})
} else {
win.loadFile(join(__dirname, '../dist/index.html'), {
hash: `/image-viewer-window?${imageParam}`
})
}
return win return win
} }
@@ -438,6 +506,7 @@ function showMainWindow() {
// 注册 IPC 处理器 // 注册 IPC 处理器
function registerIpcHandlers() { function registerIpcHandlers() {
registerNotificationHandlers()
// 配置相关 // 配置相关
ipcMain.handle('config:get', async (_, key: string) => { ipcMain.handle('config:get', async (_, key: string) => {
return configService?.get(key as any) return configService?.get(key as any)
@@ -551,6 +620,11 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('app:ignoreUpdate', async (_, version: string) => {
configService?.set('ignoredUpdateVersion', version)
return { success: true }
})
// 窗口控制 // 窗口控制
ipcMain.on('window:minimize', (event) => { ipcMain.on('window:minimize', (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() BrowserWindow.fromWebContents(event.sender)?.minimize()
@@ -673,6 +747,10 @@ function registerIpcHandlers() {
return dbPathService.scanWxids(rootPath) return dbPathService.scanWxids(rootPath)
}) })
ipcMain.handle('dbpath:scanWxidCandidates', async (_, rootPath: string) => {
return dbPathService.scanWxidCandidates(rootPath)
})
ipcMain.handle('dbpath:getDefault', async () => { ipcMain.handle('dbpath:getDefault', async () => {
return dbPathService.getDefaultPath() return dbPathService.getDefaultPath()
}) })
@@ -714,6 +792,10 @@ function registerIpcHandlers() {
return chatService.getLatestMessages(sessionId, limit) return chatService.getLatestMessages(sessionId, limit)
}) })
ipcMain.handle('chat:getNewMessages', async (_, sessionId: string, minTime: number, limit?: number) => {
return chatService.getNewMessages(sessionId, minTime, limit)
})
ipcMain.handle('chat:getContact', async (_, username: string) => { ipcMain.handle('chat:getContact', async (_, username: string) => {
return await chatService.getContact(username) return await chatService.getContact(username)
}) })
@@ -798,6 +880,17 @@ function registerIpcHandlers() {
return true return true
}) })
// Windows Hello
ipcMain.handle('auth:hello', async (event, message?: string) => {
// 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致
// 如果主窗口不存在(极其罕见),则回退到调用者窗口
const targetWin = (mainWindow && !mainWindow.isDestroyed())
? mainWindow
: (BrowserWindow.fromWebContents(event.sender) || undefined)
return windowsHelloService.verify(message, targetWin)
})
// 导出相关 // 导出相关
ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => {
const onProgress = (progress: ExportProgress) => { const onProgress = (progress: ExportProgress) => {
@@ -829,6 +922,18 @@ function registerIpcHandlers() {
return analyticsService.getTimeDistribution() return analyticsService.getTimeDistribution()
}) })
ipcMain.handle('analytics:getExcludedUsernames', async () => {
return analyticsService.getExcludedUsernames()
})
ipcMain.handle('analytics:setExcludedUsernames', async (_, usernames: string[]) => {
return analyticsService.setExcludedUsernames(usernames)
})
ipcMain.handle('analytics:getExcludeCandidates', async () => {
return analyticsService.getExcludeCandidates()
})
// 缓存管理 // 缓存管理
ipcMain.handle('cache:clearAnalytics', async () => { ipcMain.handle('cache:clearAnalytics', async () => {
return analyticsService.clearCache() return analyticsService.clearCache()
@@ -894,12 +999,21 @@ function registerIpcHandlers() {
return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime) return groupAnalyticsService.getGroupMediaStats(chatroomId, startTime, endTime)
}) })
ipcMain.handle('groupAnalytics:exportGroupMembers', async (_, chatroomId: string, outputPath: string) => {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
})
// 打开协议窗口 // 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => { ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow() createAgreementWindow()
return true return true
}) })
// 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => {
createImageViewerWindow(imagePath)
})
// 完成引导,关闭引导窗口并显示主窗口 // 完成引导,关闭引导窗口并显示主窗口
ipcMain.handle('window:completeOnboarding', async () => { ipcMain.handle('window:completeOnboarding', async () => {
try { try {
@@ -997,6 +1111,73 @@ function registerIpcHandlers() {
}) })
}) })
ipcMain.handle('dualReport:generateReport', async (_, payload: { friendUsername: string; year: number }) => {
const cfg = configService || new ConfigService()
configService = cfg
const dbPath = cfg.get('dbPath')
const decryptKey = cfg.get('decryptKey')
const wxid = cfg.get('myWxid')
const logEnabled = cfg.get('logEnabled')
const friendUsername = payload?.friendUsername
const year = payload?.year ?? 0
if (!friendUsername) {
return { success: false, error: '缺少好友用户名' }
}
const resourcesPath = app.isPackaged
? join(process.resourcesPath, 'resources')
: join(app.getAppPath(), 'resources')
const userDataPath = app.getPath('userData')
const workerPath = join(__dirname, 'dualReportWorker.js')
return await new Promise((resolve) => {
const worker = new Worker(workerPath, {
workerData: { year, friendUsername, dbPath, decryptKey, myWxid: wxid, resourcesPath, userDataPath, logEnabled }
})
const cleanup = () => {
worker.removeAllListeners()
}
worker.on('message', (msg: any) => {
if (msg && msg.type === 'dualReport:progress') {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
win.webContents.send('dualReport:progress', msg.data)
}
}
return
}
if (msg && (msg.type === 'dualReport:result' || msg.type === 'done')) {
cleanup()
void worker.terminate()
resolve(msg.data ?? msg.result)
return
}
if (msg && (msg.type === 'dualReport:error' || msg.type === 'error')) {
cleanup()
void worker.terminate()
resolve({ success: false, error: msg.error || '双人报告生成失败' })
}
})
worker.on('error', (err) => {
cleanup()
resolve({ success: false, error: String(err) })
})
worker.on('exit', (code) => {
if (code !== 0) {
cleanup()
resolve({ success: false, error: `双人报告线程异常退出: ${code}` })
}
})
})
})
ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => { ipcMain.handle('annualReport:exportImages', async (_, payload: { baseDir: string; folderName: string; images: Array<{ name: string; dataUrl: string }> }) => {
try { try {
const { baseDir, folderName, images } = payload const { baseDir, folderName, images } = payload
@@ -1060,7 +1241,16 @@ function checkForUpdatesOnStartup() {
if (result && result.updateInfo) { if (result && result.updateInfo) {
const currentVersion = app.getVersion() const currentVersion = app.getVersion()
const latestVersion = result.updateInfo.version const latestVersion = result.updateInfo.version
// 检查是否有新版本
if (latestVersion !== currentVersion && mainWindow) { if (latestVersion !== currentVersion && mainWindow) {
// 检查该版本是否被用户忽略
const ignoredVersion = configService?.get('ignoredUpdateVersion')
if (ignoredVersion === latestVersion) {
return
}
// 通知渲染进程有新版本 // 通知渲染进程有新版本
mainWindow.webContents.send('app:updateAvailable', { mainWindow.webContents.send('app:updateAvailable', {
version: latestVersion, version: latestVersion,

24
electron/nodert.d.ts vendored Normal file
View File

@@ -0,0 +1,24 @@
declare module '@nodert-win10-rs4/windows.security.credentials.ui' {
export enum UserConsentVerificationResult {
Verified = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4,
RetriesExhausted = 5,
Canceled = 6
}
export enum UserConsentVerifierAvailability {
Available = 0,
DeviceNotPresent = 1,
NotConfiguredForUser = 2,
DisabledByPolicy = 3,
DeviceBusy = 4
}
export class UserConsentVerifier {
static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void;
static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void;
}
}

View File

@@ -29,7 +29,7 @@ function enforceLocalDllPriority() {
process.env.PATH = dllPaths process.env.PATH = dllPaths
} }
console.log('[WeFlow] Environment PATH updated to enforce local DLL priority:', dllPaths)
} }
try { try {

View File

@@ -9,6 +9,24 @@ contextBridge.exposeInMainWorld('electronAPI', {
clear: () => ipcRenderer.invoke('config:clear') clear: () => ipcRenderer.invoke('config:clear')
}, },
// 通知
notification: {
show: (data: any) => ipcRenderer.invoke('notification:show', data),
close: () => ipcRenderer.invoke('notification:close'),
click: (sessionId: string) => ipcRenderer.send('notification-clicked', sessionId),
ready: () => ipcRenderer.send('notification:ready'),
resize: (width: number, height: number) => ipcRenderer.send('notification:resize', { width, height }),
onShow: (callback: (event: any, data: any) => void) => {
ipcRenderer.on('notification:show', callback)
return () => ipcRenderer.removeAllListeners('notification:show')
}
},
// 认证
auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message)
},
// 对话框 // 对话框
dialog: { dialog: {
@@ -29,6 +47,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVersion: () => ipcRenderer.invoke('app:getVersion'), getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'),
downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'),
ignoreUpdate: (version: string) => ipcRenderer.invoke('app:ignoreUpdate', version),
onDownloadProgress: (callback: (progress: any) => void) => { onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress))
return () => ipcRenderer.removeAllListeners('app:downloadProgress') return () => ipcRenderer.removeAllListeners('app:downloadProgress')
@@ -42,7 +61,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 日志 // 日志
log: { log: {
getPath: () => ipcRenderer.invoke('log:getPath'), getPath: () => ipcRenderer.invoke('log:getPath'),
read: () => ipcRenderer.invoke('log:read') read: () => ipcRenderer.invoke('log:read'),
debug: (data: any) => ipcRenderer.send('log:debug', data)
}, },
// 窗口控制 // 窗口控制
@@ -58,6 +78,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight),
resizeToFitVideo: (videoWidth: number, videoHeight: number) => resizeToFitVideo: (videoWidth: number, videoHeight: number) =>
ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight),
openImageViewerWindow: (imagePath: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
}, },
@@ -66,6 +88,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
dbPath: { dbPath: {
autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'), autoDetect: () => ipcRenderer.invoke('dbpath:autoDetect'),
scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath), scanWxids: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxids', rootPath),
scanWxidCandidates: (rootPath: string) => ipcRenderer.invoke('dbpath:scanWxidCandidates', rootPath),
getDefault: () => ipcRenderer.invoke('dbpath:getDefault') getDefault: () => ipcRenderer.invoke('dbpath:getDefault')
}, },
@@ -104,6 +127,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending), ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit, startTime, endTime, ascending),
getLatestMessages: (sessionId: string, limit?: number) => getLatestMessages: (sessionId: string, limit?: number) =>
ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit), ipcRenderer.invoke('chat:getLatestMessages', sessionId, limit),
getNewMessages: (sessionId: string, minTime: number, limit?: number) =>
ipcRenderer.invoke('chat:getNewMessages', sessionId, minTime, limit),
getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username),
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'),
@@ -125,7 +150,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
ipcRenderer.invoke('chat:execQuery', kind, path, sql), ipcRenderer.invoke('chat:execQuery', kind, path, sql),
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId) ipcRenderer.invoke('chat:getMessage', sessionId, localId),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback)
}
}, },
@@ -156,9 +185,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 数据分析 // 数据分析
analytics: { analytics: {
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), getOverallStatistics: (force?: boolean) => ipcRenderer.invoke('analytics:getOverallStatistics', force),
getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit), getContactRankings: (limit?: number) => ipcRenderer.invoke('analytics:getContactRankings', limit),
getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'), getTimeDistribution: () => ipcRenderer.invoke('analytics:getTimeDistribution'),
getExcludedUsernames: () => ipcRenderer.invoke('analytics:getExcludedUsernames'),
setExcludedUsernames: (usernames: string[]) => ipcRenderer.invoke('analytics:setExcludedUsernames', usernames),
getExcludeCandidates: () => ipcRenderer.invoke('analytics:getExcludeCandidates'),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => { onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('analytics:progress', (_, payload) => callback(payload)) ipcRenderer.on('analytics:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('analytics:progress') return () => ipcRenderer.removeAllListeners('analytics:progress')
@@ -178,7 +210,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId), getGroupMembers: (chatroomId: string) => ipcRenderer.invoke('groupAnalytics:getGroupMembers', chatroomId),
getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime), getGroupMessageRanking: (chatroomId: string, limit?: number, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMessageRanking', chatroomId, limit, startTime, endTime),
getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime), getGroupActiveHours: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupActiveHours', chatroomId, startTime, endTime),
getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime) getGroupMediaStats: (chatroomId: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('groupAnalytics:getGroupMediaStats', chatroomId, startTime, endTime),
exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath)
}, },
// 年度报告 // 年度报告
@@ -192,6 +225,14 @@ contextBridge.exposeInMainWorld('electronAPI', {
return () => ipcRenderer.removeAllListeners('annualReport:progress') return () => ipcRenderer.removeAllListeners('annualReport:progress')
} }
}, },
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) =>
ipcRenderer.invoke('dualReport:generateReport', payload),
onProgress: (callback: (payload: { status: string; progress: number }) => void) => {
ipcRenderer.on('dualReport:progress', (_, payload) => callback(payload))
return () => ipcRenderer.removeAllListeners('dualReport:progress')
}
},
// 导出 // 导出
export: { export: {

View File

@@ -3,6 +3,7 @@ import { wcdbService } from './wcdbService'
import { join } from 'path' import { join } from 'path'
import { readFile, writeFile, rm } from 'fs/promises' import { readFile, writeFile, rm } from 'fs/promises'
import { app } from 'electron' import { app } from 'electron'
import { createHash } from 'crypto'
export interface ChatStatistics { export interface ChatStatistics {
totalMessages: number totalMessages: number
@@ -46,6 +47,58 @@ class AnalyticsService {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
private normalizeUsername(username: string): string {
return username.trim().toLowerCase()
}
private normalizeExcludedUsernames(value: unknown): string[] {
if (!Array.isArray(value)) return []
const normalized = value
.map((item) => typeof item === 'string' ? item.trim().toLowerCase() : '')
.filter((item) => item.length > 0)
return Array.from(new Set(normalized))
}
private getExcludedUsernamesList(): string[] {
return this.normalizeExcludedUsernames(this.configService.get('analyticsExcludedUsernames'))
}
private getExcludedUsernamesSet(): Set<string> {
return new Set(this.getExcludedUsernamesList())
}
private escapeSqlValue(value: string): string {
return value.replace(/'/g, "''")
}
private async getAliasMap(usernames: string[]): Promise<Record<string, string>> {
const map: Record<string, string> = {}
if (usernames.length === 0) return map
const chunkSize = 200
for (let i = 0; i < usernames.length; i += chunkSize) {
const chunk = usernames.slice(i, i + chunkSize)
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',')
if (!inList) continue
const sql = `
SELECT username, alias
FROM contact
WHERE username IN (${inList})
`
const result = await wcdbService.execQuery('contact', null, sql)
if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, any>[]) {
const username = row.username || ''
const alias = row.alias || ''
if (username && alias) {
map[username] = alias
}
}
}
return map
}
private cleanAccountDirName(name: string): string { private cleanAccountDirName(name: string): string {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return trimmed if (!trimmed) return trimmed
@@ -54,7 +107,11 @@ class AnalyticsService {
if (match) return match[1] if (match) return match[1]
return trimmed return trimmed
} }
return trimmed
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
} }
private isPrivateSession(username: string, cleanedWxid: string): boolean { private isPrivateSession(username: string, cleanedWxid: string): boolean {
@@ -97,13 +154,15 @@ class AnalyticsService {
} }
private async getPrivateSessions( private async getPrivateSessions(
cleanedWxid: string cleanedWxid: string,
excludedUsernames?: Set<string>
): Promise<{ usernames: string[]; numericIds: string[] }> { ): Promise<{ usernames: string[]; numericIds: string[] }> {
const sessionResult = await wcdbService.getSessions() const sessionResult = await wcdbService.getSessions()
if (!sessionResult.success || !sessionResult.sessions) { if (!sessionResult.success || !sessionResult.sessions) {
return { usernames: [], numericIds: [] } return { usernames: [], numericIds: [] }
} }
const rows = sessionResult.sessions as Record<string, any>[] const rows = sessionResult.sessions as Record<string, any>[]
const excluded = excludedUsernames ?? this.getExcludedUsernamesSet()
const sample = rows[0] const sample = rows[0]
void sample void sample
@@ -124,7 +183,11 @@ class AnalyticsService {
return { username, idValue } return { username, idValue }
}) })
const usernames = sessions.map((s) => s.username) const usernames = sessions.map((s) => s.username)
const privateSessions = sessions.filter((s) => this.isPrivateSession(s.username, cleanedWxid)) const privateSessions = sessions.filter((s) => {
if (!this.isPrivateSession(s.username, cleanedWxid)) return false
if (excluded.size === 0) return true
return !excluded.has(this.normalizeUsername(s.username))
})
const privateUsernames = privateSessions.map((s) => s.username) const privateUsernames = privateSessions.map((s) => s.username)
const numericIds = privateSessions const numericIds = privateSessions
.map((s) => s.idValue) .map((s) => s.idValue)
@@ -177,11 +240,18 @@ class AnalyticsService {
} }
private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string { private buildAggregateCacheKey(sessionIds: string[], beginTimestamp: number, endTimestamp: number): string {
const sample = sessionIds.slice(0, 5).join(',') if (sessionIds.length === 0) {
return `${beginTimestamp}-${endTimestamp}-${sessionIds.length}-${sample}` return `${beginTimestamp}-${endTimestamp}-0-empty`
}
const normalized = Array.from(new Set(sessionIds.map((id) => String(id)))).sort()
const hash = createHash('sha1').update(normalized.join('|')).digest('hex').slice(0, 12)
return `${beginTimestamp}-${endTimestamp}-${normalized.length}-${hash}`
} }
private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> { private async computeAggregateByCursor(sessionIds: string[], beginTimestamp = 0, endTimestamp = 0): Promise<any> {
const wxid = this.configService.get('myWxid')
const cleanedWxid = wxid ? this.cleanAccountDirName(wxid) : ''
const aggregate = { const aggregate = {
total: 0, total: 0,
sent: 0, sent: 0,
@@ -206,8 +276,22 @@ class AnalyticsService {
if (endTimestamp > 0 && createTime > endTimestamp) return if (endTimestamp > 0 && createTime > endTimestamp) return
const localType = parseInt(row.local_type || row.type || '1', 10) const localType = parseInt(row.local_type || row.type || '1', 10)
const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend ?? 0 const isSendRaw = row.computed_is_send ?? row.is_send ?? row.isSend
const isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true let isSend = String(isSendRaw) === '1' || isSendRaw === 1 || isSendRaw === true
// 如果底层没有提供 is_send则根据发送者用户名推断
const senderUsername = row.sender_username || row.senderUsername || row.sender
if (isSendRaw === undefined || isSendRaw === null) {
if (senderUsername && (cleanedWxid)) {
const senderLower = String(senderUsername).toLowerCase()
const myWxidLower = cleanedWxid.toLowerCase()
isSend = (
senderLower === myWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower.startsWith(senderLower + '_'))
)
}
}
aggregate.total += 1 aggregate.total += 1
sessionStat.total += 1 sessionStat.total += 1
@@ -369,6 +453,65 @@ class AnalyticsService {
void results void results
} }
async getExcludedUsernames(): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
return { success: true, data: this.getExcludedUsernamesList() }
} catch (e) {
return { success: false, error: String(e) }
}
}
async setExcludedUsernames(usernames: string[]): Promise<{ success: boolean; data?: string[]; error?: string }> {
try {
const normalized = this.normalizeExcludedUsernames(usernames)
this.configService.set('analyticsExcludedUsernames', normalized)
await this.clearCache()
return { success: true, data: normalized }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getExcludeCandidates(): Promise<{ success: boolean; data?: Array<{ username: string; displayName: string; avatarUrl?: string; wechatId?: string }>; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success || !conn.cleanedWxid) return { success: false, error: conn.error }
const excluded = this.getExcludedUsernamesSet()
const sessionInfo = await this.getPrivateSessions(conn.cleanedWxid, new Set())
const usernames = new Set<string>(sessionInfo.usernames)
for (const name of excluded) usernames.add(name)
if (usernames.size === 0) {
return { success: true, data: [] }
}
const usernameList = Array.from(usernames)
const [displayNames, avatarUrls, aliasMap] = await Promise.all([
wcdbService.getDisplayNames(usernameList),
wcdbService.getAvatarUrls(usernameList),
this.getAliasMap(usernameList)
])
const entries = usernameList.map((username) => {
const displayName = displayNames.success && displayNames.map
? (displayNames.map[username] || username)
: username
const avatarUrl = avatarUrls.success && avatarUrls.map
? avatarUrls.map[username]
: undefined
const alias = aliasMap[username]
const wechatId = alias || (!username.startsWith('wxid_') ? username : '')
return { username, displayName, avatarUrl, wechatId }
})
return { success: true, data: entries }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getOverallStatistics(force = false): 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()

View File

@@ -69,6 +69,20 @@ export interface AnnualReportData {
phrase: string phrase: string
count: number count: number
}[] }[]
snsStats?: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
}
lostFriend: {
username: string
displayName: string
avatarUrl?: string
earlyCount: number
lateCount: number
periodDesc: string
} | null
} }
class AnnualReportService { class AnnualReportService {
@@ -101,8 +115,9 @@ class AnnualReportService {
return trimmed return trimmed
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed
return cleaned
} }
private async ensureConnectedWithConfig( private async ensureConnectedWithConfig(
@@ -397,8 +412,15 @@ class AnnualReportService {
this.reportProgress('加载会话列表...', 15, onProgress) this.reportProgress('加载会话列表...', 15, onProgress)
const startTime = Math.floor(new Date(year, 0, 1).getTime() / 1000) const isAllTime = year <= 0
const endTime = Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000) const reportYear = isAllTime ? 0 : year
const startTime = isAllTime ? 0 : Math.floor(new Date(year, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(year, 11, 31, 23, 59, 59).getTime() / 1000)
const now = new Date()
// 全局统计始终使用自然年范围 (Jan 1st - Now/YearEnd)
const actualStartTime = startTime
const actualEndTime = endTime
let totalMessages = 0 let totalMessages = 0
const contactStats = new Map<string, { sent: number; received: number }>() const contactStats = new Map<string, { sent: number; received: number }>()
@@ -420,7 +442,7 @@ class AnnualReportService {
const CONVERSATION_GAP = 3600 const CONVERSATION_GAP = 3600
this.reportProgress('统计会话消息...', 20, onProgress) this.reportProgress('统计会话消息...', 20, onProgress)
const result = await wcdbService.getAnnualReportStats(sessionIds, startTime, endTime) const result = await wcdbService.getAnnualReportStats(sessionIds, actualStartTime, actualEndTime)
if (!result.success || !result.data) { if (!result.success || !result.data) {
return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' } return { success: false, error: result.error ? `基础统计失败: ${result.error}` : '基础统计失败' }
} }
@@ -474,7 +496,7 @@ class AnnualReportService {
} }
this.reportProgress('加载扩展统计... (初始化)', 30, onProgress) this.reportProgress('加载扩展统计... (初始化)', 30, onProgress)
const extras = await wcdbService.getAnnualReportExtras(sessionIds, startTime, endTime, peakDayBegin, peakDayEnd) const extras = await wcdbService.getAnnualReportExtras(sessionIds, actualStartTime, actualEndTime, peakDayBegin, peakDayEnd)
if (extras.success && extras.data) { if (extras.success && extras.data) {
this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress) this.reportProgress('加载扩展统计... (解析热力图)', 32, onProgress)
const extrasData = extras.data as any const extrasData = extras.data as any
@@ -554,7 +576,7 @@ class AnnualReportService {
// 为保持功能完整,我们进行深度集成的轻量遍历: // 为保持功能完整,我们进行深度集成的轻量遍历:
for (let i = 0; i < sessionIds.length; i++) { for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i] const sessionId = sessionIds[i]
const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, startTime, endTime) const cursor = await wcdbService.openMessageCursorLite(sessionId, 1000, true, actualStartTime, actualEndTime)
if (!cursor.success || !cursor.cursor) continue if (!cursor.success || !cursor.cursor) continue
let lastDayIndex: number | null = null let lastDayIndex: number | null = null
@@ -575,9 +597,22 @@ class AnnualReportService {
if (!createTime) continue if (!createTime) continue
const isSendRaw = row.computed_is_send ?? row.is_send ?? '0' const isSendRaw = row.computed_is_send ?? row.is_send ?? '0'
const isSent = parseInt(isSendRaw, 10) === 1 let isSent = parseInt(isSendRaw, 10) === 1
const localType = parseInt(row.local_type || row.type || '1', 10) const localType = parseInt(row.local_type || row.type || '1', 10)
// 兼容逻辑
if (isSendRaw === undefined || isSendRaw === null || isSendRaw === '0') {
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (sender) {
const rawLower = rawWxid.toLowerCase()
const cleanedLower = cleanedWxid.toLowerCase()
if (sender === rawLower || sender === cleanedLower ||
rawLower.startsWith(sender + '_') || cleanedLower.startsWith(sender + '_')) {
isSent = true
}
}
}
// 响应速度 & 对话发起 // 响应速度 & 对话发起
if (!conversationStarts.has(sessionId)) { if (!conversationStarts.has(sessionId)) {
conversationStarts.set(sessionId, { initiated: 0, received: 0 }) conversationStarts.set(sessionId, { initiated: 0, received: 0 })
@@ -689,7 +724,7 @@ class AnnualReportService {
if (!streakComputedInLoop) { if (!streakComputedInLoop) {
this.reportProgress('计算连续聊天...', 45, onProgress) this.reportProgress('计算连续聊天...', 45, onProgress)
const streakResult = await this.computeLongestStreak(sessionIds, startTime, endTime, onProgress, 45, 75) const streakResult = await this.computeLongestStreak(sessionIds, actualStartTime, actualEndTime, onProgress, 45, 75)
if (streakResult.days > longestStreakDays) { if (streakResult.days > longestStreakDays) {
longestStreakDays = streakResult.days longestStreakDays = streakResult.days
longestStreakSessionId = streakResult.sessionId longestStreakSessionId = streakResult.sessionId
@@ -698,6 +733,42 @@ class AnnualReportService {
} }
} }
// 获取朋友圈统计
this.reportProgress('分析朋友圈数据...', 75, onProgress)
let snsStatsResult: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
} | undefined
const snsStats = await wcdbService.getSnsAnnualStats(actualStartTime, actualEndTime)
if (snsStats.success && snsStats.data) {
const d = snsStats.data
const usersToFetch = new Set<string>()
d.topLikers?.forEach((u: any) => usersToFetch.add(u.username))
d.topLiked?.forEach((u: any) => usersToFetch.add(u.username))
const snsUserIds = Array.from(usersToFetch)
const [snsDisplayNames, snsAvatarUrls] = await Promise.all([
wcdbService.getDisplayNames(snsUserIds),
wcdbService.getAvatarUrls(snsUserIds)
])
const getSnsUserInfo = (username: string) => ({
displayName: snsDisplayNames.success && snsDisplayNames.map ? (snsDisplayNames.map[username] || username) : username,
avatarUrl: snsAvatarUrls.success && snsAvatarUrls.map ? snsAvatarUrls.map[username] : undefined
})
snsStatsResult = {
totalPosts: d.totalPosts || 0,
typeCounts: d.typeCounts,
topLikers: (d.topLikers || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) })),
topLiked: (d.topLiked || []).map((u: any) => ({ ...u, ...getSnsUserInfo(u.username) }))
}
}
this.reportProgress('整理联系人信息...', 85, onProgress) this.reportProgress('整理联系人信息...', 85, onProgress)
const contactIds = Array.from(contactStats.keys()) const contactIds = Array.from(contactStats.keys())
@@ -901,8 +972,130 @@ class AnnualReportService {
.slice(0, 32) .slice(0, 32)
.map(([phrase, count]) => ({ phrase, count })) .map(([phrase, count]) => ({ phrase, count }))
// 曾经的好朋友 (Once Best Friend / Lost Friend)
let lostFriend: AnnualReportData['lostFriend'] = null
let maxEarlyCount = 80 // 最低门槛
let bestEarlyCount = 0
let bestLateCount = 0
let bestSid = ''
let bestPeriodDesc = ''
const currentMonthIndex = new Date().getMonth() + 1 // 1-12
const currentYearNum = now.getFullYear()
if (isAllTime) {
const days = Object.keys(d.daily).sort()
if (days.length >= 2) {
const firstDay = Math.floor(new Date(days[0]).getTime() / 1000)
const lastDay = Math.floor(new Date(days[days.length - 1]).getTime() / 1000)
const midPoint = Math.floor((firstDay + lastDay) / 2)
this.reportProgress('分析历史趋势 (1/2)...', 86, onProgress)
const earlyRes = await wcdbService.getAggregateStats(sessionIds, 0, midPoint)
this.reportProgress('分析历史趋势 (2/2)...', 88, onProgress)
const lateRes = await wcdbService.getAggregateStats(sessionIds, midPoint, 0)
if (earlyRes.success && lateRes.success && earlyRes.data) {
const earlyData = earlyRes.data.sessions || {}
const lateData = (lateRes.data?.sessions) || {}
for (const sid of sessionIds) {
const e = earlyData[sid] || { sent: 0, received: 0 }
const l = lateData[sid] || { sent: 0, received: 0 }
const early = (e.sent || 0) + (e.received || 0)
const late = (l.sent || 0) + (l.received || 0)
if (early > 100 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = '这段时间以来'
}
}
}
}
}
} else if (year === currentYearNum) {
// 当前年份独立获取过去12个月的滚动数据
this.reportProgress('分析近期好友趋势...', 86, onProgress)
// 往前数12个月的起点、中点、终点
const rollingStart = Math.floor(new Date(now.getFullYear(), now.getMonth() - 11, 1).getTime() / 1000)
const rollingMid = Math.floor(new Date(now.getFullYear(), now.getMonth() - 5, 1).getTime() / 1000)
const rollingEnd = Math.floor(now.getTime() / 1000)
const earlyRes = await wcdbService.getAggregateStats(sessionIds, rollingStart, rollingMid - 1)
const lateRes = await wcdbService.getAggregateStats(sessionIds, rollingMid, rollingEnd)
if (earlyRes.success && lateRes.success && earlyRes.data) {
const earlyData = earlyRes.data.sessions || {}
const lateData = lateRes.data?.sessions || {}
for (const sid of sessionIds) {
const e = earlyData[sid] || { sent: 0, received: 0 }
const l = lateData[sid] || { sent: 0, received: 0 }
const early = (e.sent || 0) + (e.received || 0)
const late = (l.sent || 0) + (l.received || 0)
if (early > 80 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = '去年的这个时候'
}
}
}
}
} else {
// 指定完整年份 (1-6 vs 7-12)
for (const [sid, stat] of Object.entries(d.sessions)) {
const s = stat as any
const mWeights = s.monthly || {}
let early = 0
let late = 0
for (let m = 1; m <= 6; m++) early += mWeights[m] || 0
for (let m = 7; m <= 12; m++) late += mWeights[m] || 0
if (early > 80 && early > late * 5) {
// 选择前期消息量最多的
if (early > maxEarlyCount) {
maxEarlyCount = early
bestEarlyCount = early
bestLateCount = late
bestSid = sid
bestPeriodDesc = `${year}年上半年`
}
}
}
}
if (bestSid) {
let info = contactInfoMap.get(bestSid)
// 如果 contactInfoMap 中没有该联系人,则单独获取
if (!info) {
const [displayNameRes, avatarUrlRes] = await Promise.all([
wcdbService.getDisplayNames([bestSid]),
wcdbService.getAvatarUrls([bestSid])
])
info = {
displayName: displayNameRes.success && displayNameRes.map ? (displayNameRes.map[bestSid] || bestSid) : bestSid,
avatarUrl: avatarUrlRes.success && avatarUrlRes.map ? avatarUrlRes.map[bestSid] : undefined
}
}
lostFriend = {
username: bestSid,
displayName: info?.displayName || bestSid,
avatarUrl: info?.avatarUrl,
earlyCount: bestEarlyCount,
lateCount: bestLateCount,
periodDesc: bestPeriodDesc
}
}
const reportData: AnnualReportData = { const reportData: AnnualReportData = {
year, year: reportYear,
totalMessages, totalMessages,
totalFriends: contactStats.size, totalFriends: contactStats.size,
coreFriends, coreFriends,
@@ -915,7 +1108,9 @@ class AnnualReportService {
mutualFriend, mutualFriend,
socialInitiative, socialInitiative,
responseSpeed, responseSpeed,
topPhrases topPhrases,
snsStats: snsStatsResult,
lostFriend
} }
return { success: true, data: reportData } return { success: true, data: reportData }

View File

@@ -1,5 +1,5 @@
import { join, dirname, basename, extname } from 'path' import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync } from 'fs' import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } from 'fs'
import * as path from 'path' import * as path from 'path'
import * as fs from 'fs' import * as fs from 'fs'
import * as https from 'https' import * as https from 'https'
@@ -7,7 +7,7 @@ import * as http from 'http'
import * as fzstd from 'fzstd' import * as fzstd from 'fzstd'
import * as crypto from 'crypto' import * as crypto from 'crypto'
import Database from 'better-sqlite3' import Database from 'better-sqlite3'
import { app } from 'electron' import { app, BrowserWindow } from 'electron'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
@@ -30,6 +30,9 @@ export interface ChatSession {
lastMsgType: number lastMsgType: number
displayName?: string displayName?: string
avatarUrl?: string avatarUrl?: string
lastMsgSender?: string
lastSenderDisplayName?: string
selfWxid?: string
} }
export interface Message { export interface Message {
@@ -152,9 +155,9 @@ class ChatService {
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed return cleaned
} }
/** /**
@@ -186,6 +189,9 @@ class ChatService {
this.connected = true this.connected = true
// 设置数据库监控
this.setupDbMonitor()
// 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接)
this.warmupMediaDbsCache() this.warmupMediaDbsCache()
@@ -196,6 +202,24 @@ class ChatService {
} }
} }
private monitorSetup = false
private setupDbMonitor() {
if (this.monitorSetup) return
this.monitorSetup = true
// 使用 C++ DLL 内部的文件监控 (ReadDirectoryChangesW)
// 这种方式更高效,且不占用 JS 线程,并能直接监听 session/message 目录变更
wcdbService.setMonitor((type, json) => {
// 广播给所有渲染进程窗口
BrowserWindow.getAllWindows().forEach((win) => {
if (!win.isDestroyed()) {
win.webContents.send('wcdb-change', { type, json })
}
})
})
}
/** /**
* 预热 media 数据库列表缓存(后台异步执行) * 预热 media 数据库列表缓存(后台异步执行)
*/ */
@@ -266,6 +290,7 @@ class ChatService {
// 转换为 ChatSession先加载缓存但不等待数据库查询 // 转换为 ChatSession先加载缓存但不等待数据库查询
const sessions: ChatSession[] = [] const sessions: ChatSession[] = []
const now = Date.now() const now = Date.now()
const myWxid = this.configService.get('myWxid')
for (const row of rows) { for (const row of rows) {
const username = const username =
@@ -319,7 +344,10 @@ class ChatService {
lastTimestamp: lastTs, lastTimestamp: lastTs,
lastMsgType, lastMsgType,
displayName, displayName,
avatarUrl avatarUrl,
lastMsgSender: row.last_msg_sender, // 数据库返回字段
lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段
selfWxid: myWxid
}) })
} }
@@ -543,7 +571,7 @@ class ChatService {
FROM contact FROM contact
` `
console.log('查询contact.db...')
const contactResult = await wcdbService.execQuery('contact', null, contactQuery) const contactResult = await wcdbService.execQuery('contact', null, contactQuery)
if (!contactResult.success || !contactResult.rows) { if (!contactResult.success || !contactResult.rows) {
@@ -551,13 +579,13 @@ class ChatService {
return { success: false, error: contactResult.error || '查询联系人失败' } return { success: false, error: contactResult.error || '查询联系人失败' }
} }
console.log('查询到', contactResult.rows.length, '条联系人记录')
const rows = contactResult.rows as Record<string, any>[] const rows = contactResult.rows as Record<string, any>[]
// 调试显示前5条数据样本 // 调试显示前5条数据样本
console.log('📋 前5条数据样本:')
rows.slice(0, 5).forEach((row, idx) => { rows.slice(0, 5).forEach((row, idx) => {
console.log(` ${idx + 1}. username: ${row.username}, local_type: ${row.local_type}, remark: ${row.remark || '无'}, nick_name: ${row.nick_name || '无'}`)
}) })
// 调试统计local_type分布 // 调试统计local_type分布
@@ -566,7 +594,7 @@ class ChatService {
const lt = row.local_type || 0 const lt = row.local_type || 0
localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1) localTypeStats.set(lt, (localTypeStats.get(lt) || 0) + 1)
}) })
console.log('📊 local_type分布:', Object.fromEntries(localTypeStats))
// 获取会话表的最后联系时间用于排序 // 获取会话表的最后联系时间用于排序
const lastContactTimeMap = new Map<string, number>() const lastContactTimeMap = new Map<string, number>()
@@ -642,13 +670,8 @@ class ChatService {
}) })
} }
console.log('过滤后得到', contacts.length, '个有效联系人')
console.log('📊 按类型统计:', {
friends: contacts.filter(c => c.type === 'friend').length,
groups: contacts.filter(c => c.type === 'group').length,
officials: contacts.filter(c => c.type === 'official').length,
other: contacts.filter(c => c.type === 'other').length
})
// 按最近联系时间排序 // 按最近联系时间排序
contacts.sort((a, b) => { contacts.sort((a, b) => {
@@ -665,7 +688,7 @@ class ChatService {
// 移除临时的lastContactTime字段 // 移除临时的lastContactTime字段
const result = contacts.map(({ lastContactTime, ...rest }) => rest) const result = contacts.map(({ lastContactTime, ...rest }) => rest)
console.log('返回', result.length, '个联系人')
return { success: true, contacts: result } return { success: true, contacts: result }
} catch (e) { } catch (e) {
console.error('ChatService: 获取通讯录失败:', e) console.error('ChatService: 获取通讯录失败:', e)
@@ -731,7 +754,7 @@ class ChatService {
// 如果需要跳过消息(offset > 0),逐批获取但不返回 // 如果需要跳过消息(offset > 0),逐批获取但不返回
if (offset > 0) { if (offset > 0) {
console.log(`[ChatService] 跳过消息: offset=${offset}`)
let skipped = 0 let skipped = 0
while (skipped < offset) { while (skipped < offset) {
const skipBatch = await wcdbService.fetchMessageBatch(state.cursor) const skipBatch = await wcdbService.fetchMessageBatch(state.cursor)
@@ -740,17 +763,17 @@ class ChatService {
return { success: false, error: skipBatch.error || '跳过消息失败' } return { success: false, error: skipBatch.error || '跳过消息失败' }
} }
if (!skipBatch.rows || skipBatch.rows.length === 0) { if (!skipBatch.rows || skipBatch.rows.length === 0) {
console.log('[ChatService] 跳过时没有更多消息')
return { success: true, messages: [], hasMore: false } return { success: true, messages: [], hasMore: false }
} }
skipped += skipBatch.rows.length skipped += skipBatch.rows.length
state.fetched += skipBatch.rows.length state.fetched += skipBatch.rows.length
if (!skipBatch.hasMore) { if (!skipBatch.hasMore) {
console.log('[ChatService] 跳过时已到达末尾')
return { success: true, messages: [], hasMore: false } return { success: true, messages: [], hasMore: false }
} }
} }
console.log(`[ChatService] 跳过完成: skipped=${skipped}, fetched=${state.fetched}`)
} }
} else if (state && offset !== state.fetched) { } else if (state && offset !== state.fetched) {
// offset 与 fetched 不匹配,说明状态不一致 // offset 与 fetched 不匹配,说明状态不一致
@@ -913,6 +936,40 @@ class ChatService {
} }
} }
async getNewMessages(sessionId: string, minTime: number, limit: number = this.messageBatchDefault): Promise<{ success: boolean; messages?: Message[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
const res = await wcdbService.getNewMessages(sessionId, minTime, limit)
if (!res.success || !res.messages) {
return { success: false, error: res.error || '获取新消息失败' }
}
// 转换为 Message 对象
const messages = this.mapRowsToMessages(res.messages as Record<string, any>[])
const normalized = this.normalizeMessageOrder(messages)
// 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = []
for (const msg of normalized) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg))
}
}
if (fixPromises.length > 0) {
await Promise.allSettled(fixPromises)
}
return { success: true, messages: normalized }
} catch (e) {
console.error('ChatService: 获取增量消息失败:', e)
return { success: false, error: String(e) }
}
}
private normalizeMessageOrder(messages: Message[]): Message[] { private normalizeMessageOrder(messages: Message[]): Message[] {
if (messages.length < 2) return messages if (messages.length < 2) return messages
const first = messages[0] const first = messages[0]
@@ -1019,13 +1076,19 @@ class ChatService {
if (senderUsername && (myWxidLower || cleanedWxidLower)) { if (senderUsername && (myWxidLower || cleanedWxidLower)) {
const senderLower = String(senderUsername).toLowerCase() const senderLower = String(senderUsername).toLowerCase()
const expectedIsSend = (senderLower === myWxidLower || senderLower === cleanedWxidLower) ? 1 : 0 const expectedIsSend = (
senderLower === myWxidLower ||
senderLower === cleanedWxidLower ||
// 兼容非 wxid 开头的账号(如果文件夹名带后缀,如 custom_backup而 sender 是 custom
(myWxidLower && myWxidLower.startsWith(senderLower + '_')) ||
(cleanedWxidLower && cleanedWxidLower.startsWith(senderLower + '_'))
) ? 1 : 0
if (isSend === null) { if (isSend === null) {
isSend = expectedIsSend isSend = expectedIsSend
// [DEBUG] Issue #34: 记录 isSend 推断过程 // [DEBUG] Issue #34: 记录 isSend 推断过程
if (expectedIsSend === 0 && localType === 1) { if (expectedIsSend === 0 && localType === 1) {
// 仅在被判为接收且是文本消息时记录,避免刷屏 // 仅在被判为接收且是文本消息时记录,避免刷屏
// console.log(`[ChatService] inferred isSend=0: sender=${senderUsername}, myWxid=${myWxid} (cleaned=${cleanedWxid})`) //
} }
} }
} else if (senderUsername && !myWxid) { } else if (senderUsername && !myWxid) {
@@ -2132,7 +2195,7 @@ class ChatService {
private decodeMaybeCompressed(raw: any, fieldName: string = 'unknown'): 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) {
@@ -2148,7 +2211,7 @@ class ChatService {
const bytes = Buffer.from(raw, 'hex') const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) { if (bytes.length > 0) {
const result = this.decodeBinaryContent(bytes, raw) const result = this.decodeBinaryContent(bytes, raw)
// console.log(`[ChatService] HEX decoded result: ${result}`) //
return result return result
} }
} }
@@ -2200,7 +2263,7 @@ class ChatService {
// 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue // 如果提供了 fallbackValue且解码结果看起来像二进制垃圾则返回 fallbackValue
if (fallbackValue && replacementCount > 0) { if (fallbackValue && replacementCount > 0) {
// console.log(`[ChatService] Binary garbage detected, using fallback: ${fallbackValue}`) //
return fallbackValue return fallbackValue
} }
@@ -2794,7 +2857,7 @@ class ChatService {
const t1 = Date.now() const t1 = Date.now()
const msgResult = await this.getMessageByLocalId(sessionId, localId) const msgResult = await this.getMessageByLocalId(sessionId, localId)
const t2 = Date.now() const t2 = Date.now()
console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) { if (msgResult.success && msgResult.message) {
const msg = msgResult.message as any const msg = msgResult.message as any
@@ -2813,7 +2876,7 @@ class ChatService {
// 检查 WAV 内存缓存 // 检查 WAV 内存缓存
const wavCache = this.voiceWavCache.get(cacheKey) const wavCache = this.voiceWavCache.get(cacheKey)
if (wavCache) { if (wavCache) {
console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavCache.toString('base64') } return { success: true, data: wavCache.toString('base64') }
} }
@@ -2825,7 +2888,7 @@ class ChatService {
const wavData = readFileSync(wavFilePath) const wavData = readFileSync(wavFilePath)
// 同时缓存到内存 // 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') } return { success: true, data: wavData.toString('base64') }
} catch (e) { } catch (e) {
console.error('[Voice] 读取缓存文件失败:', e) console.error('[Voice] 读取缓存文件失败:', e)
@@ -2855,7 +2918,7 @@ class ChatService {
// 从数据库读取 silk 数据 // 从数据库读取 silk 数据
const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates)
const t4 = Date.now() const t4 = Date.now()
console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`)
if (!silkData) { if (!silkData) {
return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' } return { success: false, error: '未找到语音数据 (请确保已在微信中播放过该语音)' }
@@ -2865,7 +2928,7 @@ class ChatService {
// 使用 silk-wasm 解码 // 使用 silk-wasm 解码
const pcmData = await this.decodeSilkToPcm(silkData, 24000) const pcmData = await this.decodeSilkToPcm(silkData, 24000)
const t6 = Date.now() const t6 = Date.now()
console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`)
if (!pcmData) { if (!pcmData) {
return { success: false, error: 'Silk 解码失败' } return { success: false, error: 'Silk 解码失败' }
@@ -2875,7 +2938,7 @@ class ChatService {
// PCM -> WAV // PCM -> WAV
const wavData = this.createWavBuffer(pcmData, 24000) const wavData = this.createWavBuffer(pcmData, 24000)
const t8 = Date.now() const t8 = Date.now()
console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`)
// 缓存 WAV 数据到内存 // 缓存 WAV 数据到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
@@ -2883,7 +2946,7 @@ class ChatService {
// 缓存 WAV 数据到文件(异步,不阻塞返回) // 缓存 WAV 数据到文件(异步,不阻塞返回)
this.cacheVoiceWavToFile(cacheKey, wavData) this.cacheVoiceWavToFile(cacheKey, wavData)
console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`)
return { success: true, data: wavData.toString('base64') } return { success: true, data: wavData.toString('base64') }
} catch (e) { } catch (e) {
console.error('ChatService: getVoiceData 失败:', e) console.error('ChatService: getVoiceData 失败:', e)
@@ -2920,11 +2983,11 @@ class ChatService {
let mediaDbFiles: string[] let mediaDbFiles: string[]
if (this.mediaDbsCache) { if (this.mediaDbsCache) {
mediaDbFiles = this.mediaDbsCache mediaDbFiles = this.mediaDbsCache
console.log(`[Voice] listMediaDbs (缓存): 0ms`)
} else { } else {
const mediaDbsResult = await wcdbService.listMediaDbs() const mediaDbsResult = await wcdbService.listMediaDbs()
const t2 = Date.now() const t2 = Date.now()
console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`)
let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : [] let files = mediaDbsResult.success && mediaDbsResult.data ? (mediaDbsResult.data as string[]) : []
@@ -2956,7 +3019,7 @@ class ChatService {
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'"
) )
const t4 = Date.now() const t4 = Date.now()
console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`)
if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) {
continue continue
@@ -2969,7 +3032,7 @@ class ChatService {
`PRAGMA table_info('${voiceTable}')` `PRAGMA table_info('${voiceTable}')`
) )
const t6 = Date.now() const t6 = Date.now()
console.log(`[Voice] 查询表结构: ${t6 - t5}ms`)
if (!columnsResult.success || !columnsResult.rows) { if (!columnsResult.success || !columnsResult.rows) {
continue continue
@@ -3006,7 +3069,7 @@ class ChatService {
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'"
) )
const t8 = Date.now() const t8 = Date.now()
console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`)
const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0)
? name2IdTablesResult.rows[0].name ? name2IdTablesResult.rows[0].name
@@ -3033,7 +3096,7 @@ class ChatService {
`SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})`
) )
const t10 = Date.now() const t10 = Date.now()
console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`)
if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) {
// 构建 chat_name_id 列表 // 构建 chat_name_id 列表
@@ -3046,13 +3109,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1`
) )
const t12 = Date.now() const t12 = Date.now()
console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3066,13 +3129,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1`
) )
const t14 = Date.now() const t14 = Date.now()
console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3085,13 +3148,13 @@ class ChatService {
`SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1`
) )
const t16 = Date.now() const t16 = Date.now()
console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`)
if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) {
const row = voiceResult.rows[0] const row = voiceResult.rows[0]
const silkData = this.decodeVoiceBlob(row.data) const silkData = this.decodeVoiceBlob(row.data)
if (silkData) { if (silkData) {
console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`)
return silkData return silkData
} }
} }
@@ -3322,7 +3385,7 @@ class ChatService {
senderWxid?: string senderWxid?: string
): Promise<{ success: boolean; transcript?: string; error?: string }> { ): Promise<{ success: boolean; transcript?: string; error?: string }> {
const startTime = Date.now() const startTime = Date.now()
console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`)
try { try {
let msgCreateTime = createTime let msgCreateTime = createTime
@@ -3333,12 +3396,12 @@ class ChatService {
const t1 = Date.now() const t1 = Date.now()
const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10))
const t2 = Date.now() const t2 = Date.now()
console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`)
if (msgResult.success && msgResult.message) { if (msgResult.success && msgResult.message) {
msgCreateTime = msgResult.message.createTime msgCreateTime = msgResult.message.createTime
serverId = msgResult.message.serverId serverId = msgResult.message.serverId
console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`)
} }
} }
@@ -3349,19 +3412,19 @@ class ChatService {
// 使用正确的 cacheKey包含 createTime // 使用正确的 cacheKey包含 createTime
const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime)
console.log(`[Transcribe] cacheKey=${cacheKey}`)
// 检查转写缓存 // 检查转写缓存
const cached = this.voiceTranscriptCache.get(cacheKey) const cached = this.voiceTranscriptCache.get(cacheKey)
if (cached) { if (cached) {
console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`)
return { success: true, transcript: cached } return { success: true, transcript: cached }
} }
// 检查是否正在转写 // 检查是否正在转写
const pending = this.voiceTranscriptPending.get(cacheKey) const pending = this.voiceTranscriptPending.get(cacheKey)
if (pending) { if (pending) {
console.log(`[Transcribe] 正在转写中,等待结果`)
return pending return pending
} }
@@ -3370,7 +3433,7 @@ class ChatService {
// 检查内存中是否有 WAV 数据 // 检查内存中是否有 WAV 数据
let wavData = this.voiceWavCache.get(cacheKey) let wavData = this.voiceWavCache.get(cacheKey)
if (wavData) { if (wavData) {
console.log(`[Transcribe] WAV内存缓存命中大小: ${wavData.length} bytes`)
} else { } else {
// 检查文件缓存 // 检查文件缓存
const voiceCacheDir = this.getVoiceCacheDir() const voiceCacheDir = this.getVoiceCacheDir()
@@ -3378,7 +3441,7 @@ class ChatService {
if (existsSync(wavFilePath)) { if (existsSync(wavFilePath)) {
try { try {
wavData = readFileSync(wavFilePath) wavData = readFileSync(wavFilePath)
console.log(`[Transcribe] WAV文件缓存命中大小: ${wavData.length} bytes`)
// 同时缓存到内存 // 同时缓存到内存
this.cacheVoiceWav(cacheKey, wavData) this.cacheVoiceWav(cacheKey, wavData)
} catch (e) { } catch (e) {
@@ -3388,39 +3451,39 @@ class ChatService {
} }
if (!wavData) { if (!wavData) {
console.log(`[Transcribe] WAV缓存未命中调用 getVoiceData`)
const t3 = Date.now() const t3 = Date.now()
// 调用 getVoiceData 获取并解码 // 调用 getVoiceData 获取并解码
const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid) const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId, senderWxid)
const t4 = Date.now() const t4 = Date.now()
console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`)
if (!voiceResult.success || !voiceResult.data) { if (!voiceResult.success || !voiceResult.data) {
console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`) console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`)
return { success: false, error: voiceResult.error || '语音解码失败' } return { success: false, error: voiceResult.error || '语音解码失败' }
} }
wavData = Buffer.from(voiceResult.data, 'base64') wavData = Buffer.from(voiceResult.data, 'base64')
console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`)
} }
// 转写 // 转写
console.log(`[Transcribe] 开始调用 transcribeWavBuffer`)
const t5 = Date.now() const t5 = Date.now()
const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => {
console.log(`[Transcribe] 部分结果: ${text}`)
onPartial?.(text) onPartial?.(text)
}) })
const t6 = Date.now() const t6 = Date.now()
console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`)
if (result.success && result.transcript) { if (result.success && result.transcript) {
console.log(`[Transcribe] 转写成功: ${result.transcript}`)
this.cacheVoiceTranscript(cacheKey, result.transcript) this.cacheVoiceTranscript(cacheKey, result.transcript)
} else { } else {
console.error(`[Transcribe] 转写失败: ${result.error}`) console.error(`[Transcribe] 转写失败: ${result.error}`)
} }
console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`)
return result return result
} catch (error) { } catch (error) {
console.error(`[Transcribe] 异常:`, error) console.error(`[Transcribe] 异常:`, error)

View File

@@ -27,17 +27,39 @@ interface ConfigSchema {
autoTranscribeVoice: boolean autoTranscribeVoice: boolean
transcribeLanguages: string[] transcribeLanguages: string[]
exportDefaultConcurrency: number exportDefaultConcurrency: number
analyticsExcludedUsernames: string[]
// 安全相关 // 安全相关
authEnabled: boolean authEnabled: boolean
authPassword: string // SHA-256 hash authPassword: string // SHA-256 hash
authUseHello: boolean authUseHello: boolean
// 更新相关
ignoredUpdateVersion: string
// 通知
notificationEnabled: boolean
notificationPosition: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
notificationFilterMode: 'all' | 'whitelist' | 'blacklist'
notificationFilterList: string[]
} }
export class ConfigService { export class ConfigService {
private store: Store<ConfigSchema> private static instance: ConfigService
private store!: Store<ConfigSchema>
static getInstance(): ConfigService {
if (!ConfigService.instance) {
ConfigService.instance = new ConfigService()
}
return ConfigService.instance
}
constructor() { constructor() {
if (ConfigService.instance) {
return ConfigService.instance
}
ConfigService.instance = this
this.store = new Store<ConfigSchema>({ this.store = new Store<ConfigSchema>({
name: 'WeFlow-config', name: 'WeFlow-config',
defaults: { defaults: {
@@ -62,10 +84,17 @@ export class ConfigService {
autoTranscribeVoice: false, autoTranscribeVoice: false,
transcribeLanguages: ['zh'], transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2, exportDefaultConcurrency: 2,
analyticsExcludedUsernames: [],
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',
authUseHello: false authUseHello: false,
ignoredUpdateVersion: '',
notificationEnabled: true,
notificationPosition: 'top-right',
notificationFilterMode: 'all',
notificationFilterList: []
} }
}) })
} }

View File

@@ -118,6 +118,48 @@ export class DbPathService {
} }
} }
/**
* 扫描目录名候选(仅包含下划线的文件夹,排除 all_users
*/
scanWxidCandidates(rootPath: string): WxidInfo[] {
const wxids: WxidInfo[] = []
try {
if (existsSync(rootPath)) {
const entries = readdirSync(rootPath)
for (const entry of entries) {
const entryPath = join(rootPath, entry)
let stat: ReturnType<typeof statSync>
try {
stat = statSync(entryPath)
} catch {
continue
}
if (!stat.isDirectory()) continue
const lower = entry.toLowerCase()
if (lower === 'all_users') continue
if (!entry.includes('_')) continue
wxids.push({ wxid: entry, modifiedTime: stat.mtimeMs })
}
}
if (wxids.length === 0) {
const rootName = basename(rootPath)
if (rootName.includes('_') && rootName.toLowerCase() !== 'all_users') {
const rootStat = statSync(rootPath)
wxids.push({ wxid: rootName, modifiedTime: rootStat.mtimeMs })
}
}
} catch { }
return wxids.sort((a, b) => {
if (b.modifiedTime !== a.modifiedTime) return b.modifiedTime - a.modifiedTime
return a.wxid.localeCompare(b.wxid)
})
}
/** /**
* 扫描 wxid 列表 * 扫描 wxid 列表
*/ */

View File

@@ -0,0 +1,462 @@
import { parentPort } from 'worker_threads'
import { wcdbService } from './wcdbService'
export interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}
export interface DualReportFirstChat {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
}
export interface DualReportStats {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
}
export interface DualReportData {
year: number
selfName: string
friendUsername: string
friendName: string
firstChat: DualReportFirstChat | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
} | null
stats: DualReportStats
topPhrases: Array<{ phrase: string; count: number }>
}
class DualReportService {
private broadcastProgress(status: string, progress: number) {
if (parentPort) {
parentPort.postMessage({
type: 'dualReport:progress',
data: { status, progress }
})
}
}
private reportProgress(status: string, progress: number, onProgress?: (status: string, progress: number) => void) {
if (onProgress) {
onProgress(status, progress)
return
}
this.broadcastProgress(status, progress)
}
private cleanAccountDirName(dirName: string): string {
const trimmed = dirName.trim()
if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1]
return trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnectedWithConfig(
dbPath: string,
decryptKey: string,
wxid: string
): Promise<{ success: boolean; cleanedWxid?: string; rawWxid?: string; error?: string }> {
if (!wxid) return { success: false, error: '未配置微信ID' }
if (!dbPath) return { success: false, error: '未配置数据库路径' }
if (!decryptKey) return { success: false, error: '未配置解密密钥' }
const cleanedWxid = this.cleanAccountDirName(wxid)
const ok = await wcdbService.open(dbPath, decryptKey, cleanedWxid)
if (!ok) return { success: false, error: 'WCDB 打开失败' }
return { success: true, cleanedWxid, rawWxid: wxid }
}
private decodeMessageContent(messageContent: any, compressContent: any): string {
let content = this.decodeMaybeCompressed(compressContent)
if (!content || content.length === 0) {
content = this.decodeMaybeCompressed(messageContent)
}
return content
}
private decodeMaybeCompressed(raw: any): string {
if (!raw) return ''
if (typeof raw === 'string') {
if (raw.length === 0) return ''
if (this.looksLikeHex(raw)) {
const bytes = Buffer.from(raw, 'hex')
if (bytes.length > 0) return this.decodeBinaryContent(bytes)
}
if (this.looksLikeBase64(raw)) {
try {
const bytes = Buffer.from(raw, 'base64')
return this.decodeBinaryContent(bytes)
} catch {
return raw
}
}
return raw
}
return ''
}
private decodeBinaryContent(data: Buffer): string {
if (data.length === 0) return ''
try {
if (data.length >= 4) {
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {
const fzstd = require('fzstd')
const decompressed = fzstd.decompress(data)
return Buffer.from(decompressed).toString('utf-8')
}
}
const decoded = data.toString('utf-8')
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
if (replacementCount < decoded.length * 0.2) {
return decoded.replace(/\uFFFD/g, '')
}
return data.toString('latin1')
} catch {
return ''
}
}
private looksLikeHex(s: string): boolean {
if (s.length % 2 !== 0) return false
return /^[0-9a-fA-F]+$/.test(s)
}
private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s)
}
private formatDateTime(milliseconds: number): string {
const dt = new Date(milliseconds)
const month = String(dt.getMonth() + 1).padStart(2, '0')
const day = String(dt.getDate()).padStart(2, '0')
const hour = String(dt.getHours()).padStart(2, '0')
const minute = String(dt.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${minute}`
}
private extractEmojiUrl(content: string): string | undefined {
if (!content) return undefined
const attrMatch = /cdnurl\s*=\s*['"]([^'"]+)['"]/i.exec(content)
if (attrMatch) {
let url = attrMatch[1].replace(/&amp;/g, '&')
try {
if (url.includes('%')) {
url = decodeURIComponent(url)
}
} catch { }
return url
}
const tagMatch = /cdnurl[^>]*>([^<]+)/i.exec(content)
return tagMatch?.[1]
}
private extractEmojiMd5(content: string): string | undefined {
if (!content) return undefined
const match = /md5="([^"]+)"/i.exec(content) || /<md5>([^<]+)<\/md5>/i.exec(content)
return match?.[1]
}
private async getDisplayName(username: string, fallback: string): Promise<string> {
const result = await wcdbService.getDisplayNames([username])
if (result.success && result.map) {
return result.map[username] || fallback
}
return fallback
}
private resolveIsSent(row: any, rawWxid?: string, cleanedWxid?: string): boolean {
const isSendRaw = row.computed_is_send ?? row.is_send
if (isSendRaw !== undefined && isSendRaw !== null) {
return parseInt(isSendRaw, 10) === 1
}
const sender = String(row.sender_username || row.sender || row.talker || '').toLowerCase()
if (!sender) return false
const rawLower = rawWxid ? rawWxid.toLowerCase() : ''
const cleanedLower = cleanedWxid ? cleanedWxid.toLowerCase() : ''
return !!(
sender === rawLower ||
sender === cleanedLower ||
(rawLower && rawLower.startsWith(sender + '_')) ||
(cleanedLower && cleanedLower.startsWith(sender + '_'))
)
}
private async getFirstMessages(
sessionId: string,
limit: number,
beginTimestamp: number,
endTimestamp: number
): Promise<any[]> {
const safeBegin = Math.max(0, beginTimestamp || 0)
const safeEnd = endTimestamp && endTimestamp > 0 ? endTimestamp : Math.floor(Date.now() / 1000)
const cursorResult = await wcdbService.openMessageCursor(sessionId, Math.max(1, limit), true, safeBegin, safeEnd)
if (!cursorResult.success || !cursorResult.cursor) return []
try {
const rows: any[] = []
let hasMore = true
while (hasMore && rows.length < limit) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
rows.push(row)
if (rows.length >= limit) break
}
hasMore = batch.hasMore === true
}
return rows.slice(0, limit)
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
}
async generateReportWithConfig(params: {
year: number
friendUsername: string
dbPath: string
decryptKey: string
wxid: string
onProgress?: (status: string, progress: number) => void
}): Promise<{ success: boolean; data?: DualReportData; error?: string }> {
try {
const { year, friendUsername, dbPath, decryptKey, wxid, onProgress } = params
this.reportProgress('正在连接数据库...', 5, onProgress)
const conn = await this.ensureConnectedWithConfig(dbPath, decryptKey, wxid)
if (!conn.success || !conn.cleanedWxid || !conn.rawWxid) return { success: false, error: conn.error }
const cleanedWxid = conn.cleanedWxid
const rawWxid = conn.rawWxid
const reportYear = year <= 0 ? 0 : year
const isAllTime = reportYear === 0
const startTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 0, 1).getTime() / 1000)
const endTime = isAllTime ? 0 : Math.floor(new Date(reportYear, 11, 31, 23, 59, 59).getTime() / 1000)
this.reportProgress('加载联系人信息...', 10, onProgress)
const friendName = await this.getDisplayName(friendUsername, friendUsername)
let myName = await this.getDisplayName(rawWxid, rawWxid)
if (myName === rawWxid && cleanedWxid && cleanedWxid !== rawWxid) {
myName = await this.getDisplayName(cleanedWxid, rawWxid)
}
this.reportProgress('获取首条聊天记录...', 15, onProgress)
const firstRows = await this.getFirstMessages(friendUsername, 3, 0, 0)
let firstChat: DualReportFirstChat | null = null
if (firstRows.length > 0) {
const row = firstRows[0]
const createTime = parseInt(row.create_time || '0', 10) * 1000
const content = this.decodeMessageContent(row.message_content, row.compress_content)
firstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(content || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
senderUsername: row.sender_username || row.sender
}
}
const firstChatMessages: DualReportMessage[] = firstRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
return {
content: String(msgContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime)
}
})
let yearFirstChat: DualReportData['yearFirstChat'] = null
if (!isAllTime) {
this.reportProgress('获取今年首次聊天...', 20, onProgress)
const firstYearRows = await this.getFirstMessages(friendUsername, 3, startTime, endTime)
if (firstYearRows.length > 0) {
const firstRow = firstYearRows[0]
const createTime = parseInt(firstRow.create_time || '0', 10) * 1000
const firstThreeMessages: DualReportMessage[] = firstYearRows.map((row) => {
const msgTime = parseInt(row.create_time || '0', 10) * 1000
const msgContent = this.decodeMessageContent(row.message_content, row.compress_content)
return {
content: String(msgContent || ''),
isSentByMe: this.resolveIsSent(row, rawWxid, cleanedWxid),
createTime: msgTime,
createTimeStr: this.formatDateTime(msgTime)
}
})
yearFirstChat = {
createTime,
createTimeStr: this.formatDateTime(createTime),
content: String(this.decodeMessageContent(firstRow.message_content, firstRow.compress_content) || ''),
isSentByMe: this.resolveIsSent(firstRow, rawWxid, cleanedWxid),
friendName,
firstThreeMessages
}
}
}
this.reportProgress('统计聊天数据...', 30, onProgress)
const stats: DualReportStats = {
totalMessages: 0,
totalWords: 0,
imageCount: 0,
voiceCount: 0,
emojiCount: 0
}
const wordCountMap = new Map<string, number>()
const myEmojiCounts = new Map<string, number>()
const friendEmojiCounts = new Map<string, number>()
const myEmojiUrlMap = new Map<string, string>()
const friendEmojiUrlMap = new Map<string, string>()
const messageCountResult = await wcdbService.getMessageCount(friendUsername)
const totalForProgress = messageCountResult.success && messageCountResult.count
? messageCountResult.count
: 0
let processed = 0
let lastProgressAt = 0
const cursorResult = await wcdbService.openMessageCursor(friendUsername, 1000, true, startTime, endTime)
if (!cursorResult.success || !cursorResult.cursor) {
return { success: false, error: cursorResult.error || '打开消息游标失败' }
}
try {
let hasMore = true
while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursorResult.cursor)
if (!batch.success || !batch.rows) break
for (const row of batch.rows) {
const localType = parseInt(row.local_type || row.type || '1', 10)
const isSent = this.resolveIsSent(row, rawWxid, cleanedWxid)
stats.totalMessages += 1
if (localType === 3) stats.imageCount += 1
if (localType === 34) stats.voiceCount += 1
if (localType === 47) {
stats.emojiCount += 1
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const md5 = this.extractEmojiMd5(content)
const url = this.extractEmojiUrl(content)
if (md5) {
const targetMap = isSent ? myEmojiCounts : friendEmojiCounts
targetMap.set(md5, (targetMap.get(md5) || 0) + 1)
if (url) {
const urlMap = isSent ? myEmojiUrlMap : friendEmojiUrlMap
if (!urlMap.has(md5)) urlMap.set(md5, url)
}
}
}
if (localType === 1 || localType === 244813135921) {
const content = this.decodeMessageContent(row.message_content, row.compress_content)
const text = String(content || '').trim()
if (text.length > 0) {
stats.totalWords += text.replace(/\s+/g, '').length
const normalized = text.replace(/\s+/g, ' ').trim()
if (normalized.length >= 2 &&
normalized.length <= 50 &&
!normalized.includes('http') &&
!normalized.includes('<') &&
!normalized.startsWith('[') &&
!normalized.startsWith('<?xml')) {
wordCountMap.set(normalized, (wordCountMap.get(normalized) || 0) + 1)
}
}
}
if (totalForProgress > 0) {
processed++
}
}
hasMore = batch.hasMore === true
const now = Date.now()
if (now - lastProgressAt > 200) {
if (totalForProgress > 0) {
const ratio = Math.min(1, processed / totalForProgress)
const progress = 30 + Math.floor(ratio * 50)
this.reportProgress('统计聊天数据...', progress, onProgress)
}
lastProgressAt = now
}
}
} finally {
await wcdbService.closeMessageCursor(cursorResult.cursor)
}
const pickTop = (map: Map<string, number>): string | undefined => {
let topKey: string | undefined
let topCount = -1
for (const [key, count] of map.entries()) {
if (count > topCount) {
topCount = count
topKey = key
}
}
return topKey
}
const myTopEmojiMd5 = pickTop(myEmojiCounts)
const friendTopEmojiMd5 = pickTop(friendEmojiCounts)
stats.myTopEmojiMd5 = myTopEmojiMd5
stats.friendTopEmojiMd5 = friendTopEmojiMd5
stats.myTopEmojiUrl = myTopEmojiMd5 ? myEmojiUrlMap.get(myTopEmojiMd5) : undefined
stats.friendTopEmojiUrl = friendTopEmojiMd5 ? friendEmojiUrlMap.get(friendTopEmojiMd5) : undefined
this.reportProgress('生成常用语词云...', 85, onProgress)
const topPhrases = Array.from(wordCountMap.entries())
.filter(([_, count]) => count >= 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 50)
.map(([phrase, count]) => ({ phrase, count }))
const reportData: DualReportData = {
year: reportYear,
selfName: myName,
friendUsername,
friendName,
firstChat,
firstChatMessages,
yearFirstChat,
stats,
topPhrases
}
this.reportProgress('双人报告生成完成', 100, onProgress)
return { success: true, data: reportData }
} catch (e) {
return { success: false, error: String(e) }
}
}
}
export const dualReportService = new DualReportService()

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
import * as fs from 'fs'
import * as path from 'path'
import ExcelJS from 'exceljs'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { chatService } from './chatService'
export interface GroupChatInfo { export interface GroupChatInfo {
username: string username: string
@@ -12,6 +16,10 @@ export interface GroupMember {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
} }
export interface GroupMessageRank { export interface GroupMessageRank {
@@ -41,16 +49,45 @@ class GroupAnalyticsService {
this.configService = new ConfigService() this.configService = new ConfigService()
} }
// 并发控制:限制同时执行的 Promise 数量
private async parallelLimit<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results: R[] = new Array(items.length)
let currentIndex = 0
async function runNext(): Promise<void> {
while (currentIndex < items.length) {
const index = currentIndex++
results[index] = await fn(items[index], index)
}
}
const workers = Array(Math.min(limit, items.length))
.fill(null)
.map(() => runNext())
await Promise.all(workers)
return results
}
private cleanAccountDirName(name: string): string { private cleanAccountDirName(name: string): string {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return trimmed if (!trimmed) return trimmed
if (trimmed.toLowerCase().startsWith('wxid_')) { if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i) const match = trimmed.match(/^(wxid_[^_]+)/i)
if (match) return match[1] if (match) return match[1]
}
return trimmed return trimmed
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return cleaned
}
private async ensureConnected(): Promise<{ success: boolean; error?: string }> { private async ensureConnected(): Promise<{ success: boolean; error?: string }> {
const wxid = this.configService.get('myWxid') const wxid = this.configService.get('myWxid')
const dbPath = this.configService.get('dbPath') const dbPath = this.configService.get('dbPath')
@@ -65,6 +102,56 @@ class GroupAnalyticsService {
return { success: true } return { success: true }
} }
/**
* 从 DLL 获取群成员的群昵称
*/
private async getGroupNicknamesForRoom(chatroomId: string): Promise<Map<string, string>> {
try {
const result = await wcdbService.getGroupNicknames(chatroomId)
if (result.success && result.nicknames) {
return new Map(Object.entries(result.nicknames))
}
return new Map<string, string>()
} catch (e) {
console.error('getGroupNicknamesForRoom error:', e)
return new Map<string, string>()
}
}
private escapeCsvValue(value: string): string {
if (value == null) return ''
const str = String(value)
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
private normalizeGroupNickname(value: string, wxid: string, fallback: string): string {
const trimmed = (value || '').trim()
if (!trimmed) return fallback
if (/^["'@]+$/.test(trimmed)) return fallback
if (trimmed.toLowerCase() === (wxid || '').toLowerCase()) return fallback
return trimmed
}
private sanitizeWorksheetName(name: string): string {
const cleaned = (name || '').replace(/[*?:\\/\\[\\]]/g, '_').trim()
const limited = cleaned.slice(0, 31)
return limited || 'Sheet1'
}
private formatDateTime(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
const year = date.getFullYear()
const month = pad(date.getMonth() + 1)
const day = pad(date.getDate())
const hour = pad(date.getHours())
const minute = pad(date.getMinutes())
const second = pad(date.getSeconds())
return `${year}-${month}-${day} ${hour}:${minute}:${second}`
}
async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> { async getGroupChats(): Promise<{ success: boolean; data?: GroupChatInfo[]; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()
@@ -80,23 +167,38 @@ class GroupAnalyticsService {
.map((row) => row.username || row.user_name || row.userName || '') .map((row) => row.username || row.user_name || row.userName || '')
.filter((username) => username.includes('@chatroom')) .filter((username) => username.includes('@chatroom'))
const [displayNames, avatarUrls, memberCounts] = await Promise.all([ const [memberCounts, contactInfo] = await Promise.all([
wcdbService.getDisplayNames(groupIds), wcdbService.getGroupMemberCounts(groupIds),
wcdbService.getAvatarUrls(groupIds), chatService.enrichSessionsContactInfo(groupIds)
wcdbService.getGroupMemberCounts(groupIds)
]) ])
let fallbackNames: { success: boolean; map?: Record<string, string> } | null = null
let fallbackAvatars: { success: boolean; map?: Record<string, string> } | null = null
if (!contactInfo.success || !contactInfo.contacts) {
const [displayNames, avatarUrls] = await Promise.all([
wcdbService.getDisplayNames(groupIds),
wcdbService.getAvatarUrls(groupIds)
])
fallbackNames = displayNames
fallbackAvatars = avatarUrls
}
const groups: GroupChatInfo[] = [] const groups: GroupChatInfo[] = []
for (const groupId of groupIds) { for (const groupId of groupIds) {
const contact = contactInfo.success && contactInfo.contacts ? contactInfo.contacts[groupId] : undefined
const displayName = contact?.displayName ||
(fallbackNames && fallbackNames.success && fallbackNames.map ? (fallbackNames.map[groupId] || '') : '') ||
groupId
const avatarUrl = contact?.avatarUrl ||
(fallbackAvatars && fallbackAvatars.success && fallbackAvatars.map ? fallbackAvatars.map[groupId] : undefined)
groups.push({ groups.push({
username: groupId, username: groupId,
displayName: displayNames.success && displayNames.map displayName,
? (displayNames.map[groupId] || groupId)
: groupId,
memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number' memberCount: memberCounts.success && memberCounts.map && typeof memberCounts.map[groupId] === 'number'
? memberCounts.map[groupId] ? memberCounts.map[groupId]
: 0, : 0,
avatarUrl: avatarUrls.success && avatarUrls.map ? avatarUrls.map[groupId] : undefined avatarUrl
}) })
} }
@@ -118,14 +220,55 @@ class GroupAnalyticsService {
} }
const members = result.members as { username: string; avatarUrl?: string }[] const members = result.members as { username: string; avatarUrl?: string }[]
const usernames = members.map((m) => m.username) const usernames = members.map((m) => m.username).filter(Boolean)
const displayNames = await wcdbService.getDisplayNames(usernames)
const data: GroupMember[] = members.map((m) => ({ const [displayNames, groupNicknames] = await Promise.all([
username: m.username, wcdbService.getDisplayNames(usernames),
displayName: displayNames.success && displayNames.map ? (displayNames.map[m.username] || m.username) : m.username, this.getGroupNicknamesForRoom(chatroomId)
])
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const contactResult = await wcdbService.getContact(username)
if (contactResult.success && contactResult.contact) {
const contact = contactResult.contact as any
contactMap.set(username, {
remark: contact.remark || '',
nickName: contact.nickName || contact.nick_name || '',
alias: contact.alias || ''
})
} else {
contactMap.set(username, { remark: '', nickName: '', alias: '' })
}
})
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
const data: GroupMember[] = members.map((m) => {
const wxid = m.username || ''
const displayName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || wxid) : wxid
const contact = contactMap.get(wxid)
const nickname = contact?.nickName || ''
const remark = contact?.remark || ''
const alias = contact?.alias || ''
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
const normalizedWxid = this.cleanAccountDirName(wxid)
const groupNickname = this.normalizeGroupNickname(
rawGroupNickname,
normalizedWxid === myWxid ? myWxid : wxid,
''
)
return {
username: wxid,
displayName,
nickname,
alias,
remark,
groupNickname,
avatarUrl: m.avatarUrl avatarUrl: m.avatarUrl
})) }
})
return { success: true, data } return { success: true, data }
} catch (e) { } catch (e) {
@@ -248,6 +391,187 @@ class GroupAnalyticsService {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const exportDate = new Date()
const exportTime = this.formatDateTime(exportDate)
const exportVersion = '0.0.2'
const exportGenerator = 'WeFlow'
const exportPlatform = 'wechat'
const groupDisplay = await wcdbService.getDisplayNames([chatroomId])
const groupName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[chatroomId] || chatroomId)
: chatroomId
const groupContact = await wcdbService.getContact(chatroomId)
const sessionRemark = (groupContact.success && groupContact.contact)
? (groupContact.contact.remark || '')
: ''
const membersResult = await wcdbService.getGroupMembers(chatroomId)
if (!membersResult.success || !membersResult.members) {
return { success: false, error: membersResult.error || '获取群成员失败' }
}
const members = membersResult.members as { username: string; avatarUrl?: string }[]
if (members.length === 0) {
return { success: false, error: '群成员为空' }
}
const usernames = members.map((m) => m.username).filter(Boolean)
const [displayNames, groupNicknames] = await Promise.all([
wcdbService.getDisplayNames(usernames),
this.getGroupNicknamesForRoom(chatroomId)
])
const contactMap = new Map<string, { remark?: string; nickName?: string; alias?: string }>()
const concurrency = 6
await this.parallelLimit(usernames, concurrency, async (username) => {
const result = await wcdbService.getContact(username)
if (result.success && result.contact) {
const contact = result.contact as any
contactMap.set(username, {
remark: contact.remark || '',
nickName: contact.nickName || contact.nick_name || '',
alias: contact.alias || ''
})
} else {
contactMap.set(username, { remark: '', nickName: '', alias: '' })
}
})
const infoTitleRow = ['会话信息']
const infoRow = ['微信ID', chatroomId, '', '昵称', groupName, '备注', sessionRemark || '', '']
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
const header = ['微信昵称', '微信备注', '群昵称', 'wxid', '微信号']
const rows: string[][] = [infoTitleRow, infoRow, metaRow, header]
const myWxid = this.cleanAccountDirName(this.configService.get('myWxid') || '')
for (const member of members) {
const wxid = member.username
const normalizedWxid = this.cleanAccountDirName(wxid || '')
const contact = contactMap.get(wxid)
const fallbackName = displayNames.success && displayNames.map ? (displayNames.map[wxid] || '') : ''
const nickName = contact?.nickName || fallbackName || ''
const remark = contact?.remark || ''
const rawGroupNickname = groupNicknames.get(wxid.toLowerCase()) || ''
const alias = contact?.alias || ''
const groupNickname = this.normalizeGroupNickname(
rawGroupNickname,
normalizedWxid === myWxid ? myWxid : wxid,
''
)
rows.push([nickName, remark, groupNickname, wxid, alias])
}
const ext = path.extname(outputPath).toLowerCase()
if (ext === '.csv') {
const csvLines = rows.map((row) => row.map((cell) => this.escapeCsvValue(cell)).join(','))
const content = '\ufeff' + csvLines.join('\n')
fs.writeFileSync(outputPath, content, 'utf8')
} else {
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet(this.sanitizeWorksheetName('群成员列表'))
let currentRow = 1
const titleCell = sheet.getCell(currentRow, 1)
titleCell.value = '会话信息'
titleCell.font = { name: 'Calibri', bold: true, size: 11 }
titleCell.alignment = { vertical: 'middle', horizontal: 'left' }
sheet.getRow(currentRow).height = 25
currentRow++
sheet.getCell(currentRow, 1).value = '微信ID'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 2, currentRow, 3)
sheet.getCell(currentRow, 2).value = chatroomId
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 4).value = '昵称'
sheet.getCell(currentRow, 4).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 5).value = groupName
sheet.getCell(currentRow, 5).font = { name: 'Calibri', size: 11 }
sheet.getCell(currentRow, 6).value = '备注'
sheet.getCell(currentRow, 6).font = { name: 'Calibri', bold: true, size: 11 }
sheet.mergeCells(currentRow, 7, currentRow, 8)
sheet.getCell(currentRow, 7).value = sessionRemark
sheet.getCell(currentRow, 7).font = { name: 'Calibri', size: 11 }
sheet.getRow(currentRow).height = 20
currentRow++
sheet.getCell(currentRow, 1).value = '导出工具'
sheet.getCell(currentRow, 1).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 2).value = exportGenerator
sheet.getCell(currentRow, 2).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 3).value = '导出版本'
sheet.getCell(currentRow, 3).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 4).value = exportVersion
sheet.getCell(currentRow, 4).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 5).value = '平台'
sheet.getCell(currentRow, 5).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 6).value = exportPlatform
sheet.getCell(currentRow, 6).font = { name: 'Calibri', size: 10 }
sheet.getCell(currentRow, 7).value = '导出时间'
sheet.getCell(currentRow, 7).font = { name: 'Calibri', bold: true, size: 11 }
sheet.getCell(currentRow, 8).value = exportTime
sheet.getCell(currentRow, 8).font = { name: 'Calibri', size: 10 }
sheet.getRow(currentRow).height = 20
currentRow++
const headerRow = sheet.getRow(currentRow)
headerRow.height = 22
header.forEach((text, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = text
cell.font = { name: 'Calibri', bold: true, size: 11 }
})
currentRow++
sheet.getColumn(1).width = 28
sheet.getColumn(2).width = 28
sheet.getColumn(3).width = 28
sheet.getColumn(4).width = 36
sheet.getColumn(5).width = 28
sheet.getColumn(6).width = 18
sheet.getColumn(7).width = 24
sheet.getColumn(8).width = 22
for (let i = 4; i < rows.length; i++) {
const [nickName, remark, groupNickname, wxid, alias] = rows[i]
const row = sheet.getRow(currentRow)
row.getCell(1).value = nickName
row.getCell(2).value = remark
row.getCell(3).value = groupNickname
row.getCell(4).value = wxid
row.getCell(5).value = alias
row.alignment = { vertical: 'top', wrapText: true }
currentRow++
}
await workbook.xlsx.writeFile(outputPath)
}
return { success: true, count: members.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
} }
export const groupAnalyticsService = new GroupAnalyticsService() export const groupAnalyticsService = new GroupAnalyticsService()

View File

@@ -380,9 +380,9 @@ export class ImageDecryptService {
} }
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
if (suffixMatch) return suffixMatch[1] const cleaned = suffixMatch ? suffixMatch[1] : trimmed
return trimmed return cleaned
} }
private async resolveDatPath( private async resolveDatPath(
@@ -415,10 +415,16 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null不再搜索 // hardlink 找到的是缩略图,但要求高清图
if (!allowThumbnail && isThumb) { // 尝试在同一目录下查找高清图变体(快速查找,不遍历)
return null const hdPath = this.findHdVariantInSameDir(hardlinkPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
// 没找到高清图,返回 null不进行全局搜索
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 }) this.logInfo('[ImageDecrypt] hardlink miss (md5)', { imageMd5 })
if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) { if (imageDatName && this.looksLikeMd5(imageDatName) && imageDatName !== imageMd5) {
@@ -431,9 +437,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, fallbackPath) this.cacheDatPath(accountDir, imageDatName, fallbackPath)
return fallbackPath return fallbackPath
} }
if (!allowThumbnail && isThumb) { // 找到缩略图但要求高清图,尝试同目录查找高清图变体
return null const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
@@ -449,10 +459,13 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hardlinkPath) this.cacheDatPath(accountDir, imageDatName, hardlinkPath)
return hardlinkPath return hardlinkPath
} }
// hardlink 找到的是缩略图,但要求高清图,直接返回 null // hardlink 找到的是缩略图,但要求高清图
if (!allowThumbnail && isThumb) { const hdPath = this.findHdVariantInSameDir(hardlinkPath)
return null if (hdPath) {
this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath
} }
return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
@@ -467,6 +480,9 @@ export class ImageDecryptService {
const cached = this.resolvedCache.get(imageDatName) const cached = this.resolvedCache.get(imageDatName)
if (cached && existsSync(cached)) { if (cached && existsSync(cached)) {
if (allowThumbnail || !this.isThumbnailPath(cached)) return cached if (allowThumbnail || !this.isThumbnailPath(cached)) return cached
// 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath
} }
} }
@@ -761,6 +777,17 @@ export class ImageDecryptService {
const root = join(accountDir, 'msg', 'attach') const root = join(accountDir, 'msg', 'attach')
if (!existsSync(root)) return null if (!existsSync(root)) return null
// 优化1快速概率性查找
// 包含1. 基于文件名的前缀猜测 (旧版)
// 2. 基于日期的最近月份扫描 (新版无索引时)
const fastHit = await this.fastProbabilisticSearch(root, datName)
if (fastHit) {
this.resolvedCache.set(key, fastHit)
return fastHit
}
// 优化2兜底扫描 (异步非阻塞)
const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly) const found = await this.walkForDatInWorker(root, datName.toLowerCase(), 8, allowThumbnail, thumbOnly)
if (found) { if (found) {
this.resolvedCache.set(key, found) this.resolvedCache.set(key, found)
@@ -769,6 +796,134 @@ export class ImageDecryptService {
return null return null
} }
/**
* 基于文件名的哈希特征猜测可能的路径
* 包含1. 微信旧版结构 filename.substr(0, 2)/...
* 2. 微信新版结构 msg/attach/{hash}/{YYYY-MM}/Img/filename
*/
private async fastProbabilisticSearch(root: string, datName: string): Promise<string | null> {
const { promises: fs } = require('fs')
const { join } = require('path')
try {
// --- 策略 A: 旧版路径猜测 (msg/attach/xx/yy/...) ---
const lowerName = datName.toLowerCase()
let baseName = lowerName
if (baseName.endsWith('.dat')) {
baseName = baseName.slice(0, -4)
if (baseName.endsWith('_t') || baseName.endsWith('.t') || baseName.endsWith('_hd')) {
baseName = baseName.slice(0, -3)
} else if (baseName.endsWith('_thumb')) {
baseName = baseName.slice(0, -6)
}
}
const candidates: string[] = []
if (/^[a-f0-9]{32}$/.test(baseName)) {
const dir1 = baseName.substring(0, 2)
const dir2 = baseName.substring(2, 4)
candidates.push(
join(root, dir1, dir2, datName),
join(root, dir1, dir2, 'Img', datName),
join(root, dir1, dir2, 'mg', datName),
join(root, dir1, dir2, 'Image', datName)
)
}
for (const path of candidates) {
try {
await fs.access(path)
return path
} catch { }
}
// --- 策略 B: 新版 Session 哈希路径猜测 ---
try {
const entries = await fs.readdir(root, { withFileTypes: true })
const sessionDirs = entries
.filter((e: any) => e.isDirectory() && e.name.length === 32 && /^[a-f0-9]+$/i.test(e.name))
.map((e: any) => e.name)
if (sessionDirs.length === 0) return null
const now = new Date()
const months: string[] = []
for (let i = 0; i < 2; i++) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
const mStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
months.push(mStr)
}
const targetNames = [datName]
if (baseName !== lowerName) {
targetNames.push(`${baseName}.dat`)
targetNames.push(`${baseName}_t.dat`)
targetNames.push(`${baseName}_thumb.dat`)
}
const batchSize = 20
for (let i = 0; i < sessionDirs.length; i += batchSize) {
const batch = sessionDirs.slice(i, i + batchSize)
const tasks = batch.map(async (sessDir: string) => {
for (const month of months) {
const subDirs = ['Img', 'Image']
for (const sub of subDirs) {
const dirPath = join(root, sessDir, month, sub)
try { await fs.access(dirPath) } catch { continue }
for (const name of targetNames) {
const p = join(dirPath, name)
try { await fs.access(p); return p } catch { }
}
}
}
return null
})
const results = await Promise.all(tasks)
const hit = results.find(r => r !== null)
if (hit) return hit
}
} catch { }
} catch { }
return null
}
/**
* 在同一目录下查找高清图变体
* 缩略图: xxx_t.dat -> 高清图: xxx_h.dat 或 xxx.dat
*/
private findHdVariantInSameDir(thumbPath: string): string | null {
try {
const dir = dirname(thumbPath)
const fileName = basename(thumbPath).toLowerCase()
// 提取基础名称(去掉 _t.dat 或 .t.dat
let baseName = fileName
if (baseName.endsWith('_t.dat')) {
baseName = baseName.slice(0, -6)
} else if (baseName.endsWith('.t.dat')) {
baseName = baseName.slice(0, -6)
} else {
return null
}
// 尝试查找高清图变体
const variants = [
`${baseName}_h.dat`,
`${baseName}.h.dat`,
`${baseName}.dat`
]
for (const variant of variants) {
const variantPath = join(dir, variant)
if (existsSync(variantPath)) {
return variantPath
}
}
} catch { }
return null
}
private async searchDatFileInDir( private async searchDatFileInDir(
dirPath: string, dirPath: string,
datName: string, datName: string,

View File

@@ -43,6 +43,7 @@ export class KeyService {
private GetWindowThreadProcessId: any = null private GetWindowThreadProcessId: any = null
private IsWindowVisible: any = null private IsWindowVisible: any = null
private EnumChildWindows: any = null private EnumChildWindows: any = null
private PostMessageW: any = null
private WNDENUMPROC_PTR: any = null private WNDENUMPROC_PTR: any = null
// Advapi32 // Advapi32
@@ -57,6 +58,7 @@ export class KeyService {
private readonly HKEY_LOCAL_MACHINE = 0x80000002 private readonly HKEY_LOCAL_MACHINE = 0x80000002
private readonly HKEY_CURRENT_USER = 0x80000001 private readonly HKEY_CURRENT_USER = 0x80000001
private readonly ERROR_SUCCESS = 0 private readonly ERROR_SUCCESS = 0
private readonly WM_CLOSE = 0x0010
private getDllPath(): string { private getDllPath(): string {
const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production' const isPackaged = typeof app !== 'undefined' && app ? app.isPackaged : process.env.NODE_ENV === 'production'
@@ -114,13 +116,13 @@ export class KeyService {
// 检查是否已经有本地副本,如果有就使用它 // 检查是否已经有本地副本,如果有就使用它
if (existsSync(localPath)) { if (existsSync(localPath)) {
console.log(`使用已存在的 DLL 本地副本: ${localPath}`)
return localPath return localPath
} }
console.log(`检测到网络路径 DLL正在复制到本地: ${originalPath} -> ${localPath}`)
copyFileSync(originalPath, localPath) copyFileSync(originalPath, localPath)
console.log('DLL 本地化成功')
return localPath return localPath
} catch (e) { } catch (e) {
console.error('DLL 本地化失败:', e) console.error('DLL 本地化失败:', e)
@@ -144,7 +146,7 @@ export class KeyService {
// 检查是否为网络路径,如果是则本地化 // 检查是否为网络路径,如果是则本地化
if (this.isNetworkPath(dllPath)) { if (this.isNetworkPath(dllPath)) {
console.log('检测到网络路径,将进行本地化处理')
dllPath = this.localizeNetworkDll(dllPath) dllPath = this.localizeNetworkDll(dllPath)
} }
@@ -224,6 +226,7 @@ export class KeyService {
this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumWindows = this.user32.func('EnumWindows', 'bool', [this.WNDENUMPROC_PTR, 'intptr_t'])
this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t']) this.EnumChildWindows = this.user32.func('EnumChildWindows', 'bool', ['void*', this.WNDENUMPROC_PTR, 'intptr_t'])
this.PostMessageW = this.user32.func('PostMessageW', 'bool', ['void*', 'uint32', 'uintptr_t', 'intptr_t'])
this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int']) this.GetWindowTextW = this.user32.func('GetWindowTextW', 'int', ['void*', this.koffi.out('uint16*'), 'int'])
this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*']) this.GetWindowTextLengthW = this.user32.func('GetWindowTextLengthW', 'int', ['void*'])
@@ -344,7 +347,7 @@ export class KeyService {
if (pid) { if (pid) {
const runPath = await this.getProcessExecutablePath(pid) const runPath = await this.getProcessExecutablePath(pid)
if (runPath && existsSync(runPath)) { if (runPath && existsSync(runPath)) {
console.log('发现正在运行的微信进程,使用路径:', runPath)
return runPath return runPath
} }
} }
@@ -437,16 +440,60 @@ export class KeyService {
return fallbackPid ?? null return fallbackPid ?? null
} }
private async killWeChatProcesses() { private async waitForWeChatExit(timeoutMs = 8000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const weixinPid = await this.findPidByImageName('Weixin.exe')
const wechatPid = await this.findPidByImageName('WeChat.exe')
if (!weixinPid && !wechatPid) return true
await new Promise(r => setTimeout(r, 400))
}
return false
}
private async closeWeChatWindows(): Promise<boolean> {
if (!this.ensureUser32()) return false
let requested = false
const enumWindowsCallback = this.koffi.register((hWnd: any, lParam: any) => {
if (!this.IsWindowVisible(hWnd)) return true
const title = this.getWindowTitle(hWnd)
const className = this.getClassName(hWnd)
const classLower = (className || '').toLowerCase()
const isWeChatWindow = this.isWeChatWindowTitle(title) || classLower.includes('wechat') || classLower.includes('weixin')
if (!isWeChatWindow) return true
requested = true
try { try {
await execFileAsync('taskkill', ['/F', '/IM', 'Weixin.exe']) this.PostMessageW?.(hWnd, this.WM_CLOSE, 0, 0)
await execFileAsync('taskkill', ['/F', '/IM', 'WeChat.exe']) } catch { }
return true
}, this.WNDENUMPROC_PTR)
this.EnumWindows(enumWindowsCallback, 0)
this.koffi.unregister(enumWindowsCallback)
return requested
}
private async killWeChatProcesses(): Promise<boolean> {
const requested = await this.closeWeChatWindows()
if (requested) {
const gracefulOk = await this.waitForWeChatExit(1500)
if (gracefulOk) return true
}
try {
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'Weixin.exe'])
await execFileAsync('taskkill', ['/F', '/T', '/IM', 'WeChat.exe'])
} catch (e) { } catch (e) {
// Ignore if not found // Ignore if not found
} }
await new Promise(r => setTimeout(r, 1000))
return await this.waitForWeChatExit(5000)
} }
// --- Window Detection --- // --- Window Detection ---
private getWindowTitle(hWnd: any): string { private getWindowTitle(hWnd: any): string {
@@ -605,12 +652,21 @@ export class KeyService {
} }
// 2. Restart WeChat // 2. Restart WeChat
onStatus?.('正在重启微信以进行获取...', 0) onStatus?.('正在关闭微信以进行获取...', 0)
await this.killWeChatProcesses() const closed = await this.killWeChatProcesses()
if (!closed) {
const err = '无法自动关闭微信,请手动退出后重试'
onStatus?.(err, 2)
return { success: false, error: err }
}
// 3. Launch // 3. Launch
onStatus?.('正在启动微信...', 0) onStatus?.('正在启动微信...', 0)
const sub = spawn(wechatPath, { detached: true, stdio: 'ignore' }) const sub = spawn(wechatPath, {
detached: true,
stdio: 'ignore',
cwd: dirname(wechatPath)
})
sub.unref() sub.unref()
// 4. Wait for Window & Get PID (Crucial change: discover PID from window) // 4. Wait for Window & Get PID (Crucial change: discover PID from window)

View File

@@ -57,15 +57,11 @@ class SnsService {
} }
async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> {
console.log('[SnsService] getTimeline called with:', { limit, offset, usernames, keyword, startTime, endTime })
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
console.log('[SnsService] getSnsTimeline result:', {
success: result.success,
timelineCount: result.timeline?.length,
error: result.error
})
if (result.success && result.timeline) { if (result.success && result.timeline) {
const enrichedTimeline = result.timeline.map((post: any, index: number) => { const enrichedTimeline = result.timeline.map((post: any, index: number) => {
@@ -121,11 +117,11 @@ class SnsService {
} }
}) })
console.log('[SnsService] Returning enriched timeline with', enrichedTimeline.length, 'posts')
return { ...result, timeline: enrichedTimeline } return { ...result, timeline: enrichedTimeline }
} }
console.log('[SnsService] Returning result:', result)
return result return result
} }
async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> {

View File

@@ -97,7 +97,7 @@ class VideoService {
return realMd5 return realMd5
} }
} catch (e) { } catch (e) {
// Silently fail // 忽略错误
} }
} }
} }
@@ -105,10 +105,21 @@ class VideoService {
// 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db // 方法2使用 wcdbService.execQuery 查询加密的 hardlink.db
if (dbPath) { if (dbPath) {
const encryptedDbPaths = [ // 检查 dbPath 是否已经包含 wxid
join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), const dbPathLower = dbPath.toLowerCase()
join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') const wxidLower = wxid.toLowerCase()
] const cleanedWxidLower = cleanedWxid.toLowerCase()
const dbPathContainsWxid = dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxidLower)
const encryptedDbPaths: string[] = []
if (dbPathContainsWxid) {
// dbPath 已包含 wxid不需要再拼接
encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db'))
} else {
// dbPath 不包含 wxid需要拼接
encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'))
encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db'))
}
for (const p of encryptedDbPaths) { for (const p of encryptedDbPaths) {
if (existsSync(p)) { if (existsSync(p)) {
@@ -129,6 +140,7 @@ class VideoService {
} }
} }
} catch (e) { } catch (e) {
// 忽略错误
} }
} }
} }
@@ -155,7 +167,6 @@ class VideoService {
* 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg
*/ */
async getVideoInfo(videoMd5: string): Promise<VideoInfo> { async getVideoInfo(videoMd5: string): Promise<VideoInfo> {
const dbPath = this.getDbPath() const dbPath = this.getDbPath()
const wxid = this.getMyWxid() const wxid = this.getMyWxid()
@@ -166,7 +177,19 @@ class VideoService {
// 先尝试从数据库查询真正的视频文件名 // 先尝试从数据库查询真正的视频文件名
const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5
const videoBaseDir = join(dbPath, wxid, 'msg', 'video') // 检查 dbPath 是否已经包含 wxid避免重复拼接
const dbPathLower = dbPath.toLowerCase()
const wxidLower = wxid.toLowerCase()
const cleanedWxid = this.cleanWxid(wxid)
let videoBaseDir: string
if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) {
// dbPath 已经包含 wxid直接使用
videoBaseDir = join(dbPath, 'msg', 'video')
} else {
// dbPath 不包含 wxid需要拼接
videoBaseDir = join(dbPath, wxid, 'msg', 'video')
}
if (!existsSync(videoBaseDir)) { if (!existsSync(videoBaseDir)) {
return { exists: false } return { exists: false }
@@ -202,7 +225,7 @@ class VideoService {
} }
} }
} catch (e) { } catch (e) {
console.error('[VideoService] Error searching for video:', e) // 忽略错误
} }
return { exists: false } return { exists: false }

View File

@@ -224,12 +224,12 @@ export class VoiceTranscribeService {
let finalTranscript = '' let finalTranscript = ''
worker.on('message', (msg: any) => { worker.on('message', (msg: any) => {
console.log('[VoiceTranscribe] Worker 消息:', msg)
if (msg.type === 'partial') { if (msg.type === 'partial') {
onPartial?.(msg.text) onPartial?.(msg.text)
} else if (msg.type === 'final') { } else if (msg.type === 'final') {
finalTranscript = msg.text finalTranscript = msg.text
console.log('[VoiceTranscribe] 最终文本:', finalTranscript)
resolve({ success: true, transcript: finalTranscript }) resolve({ success: true, transcript: finalTranscript })
worker.terminate() worker.terminate()
} else if (msg.type === 'error') { } else if (msg.type === 'error') {

View File

@@ -35,6 +35,7 @@ export class WcdbCore {
private wcdbGetGroupMemberCount: any = null private wcdbGetGroupMemberCount: any = null
private wcdbGetGroupMemberCounts: any = null private wcdbGetGroupMemberCounts: any = null
private wcdbGetGroupMembers: any = null private wcdbGetGroupMembers: any = null
private wcdbGetGroupNicknames: any = null
private wcdbGetMessageTables: any = null private wcdbGetMessageTables: any = null
private wcdbGetMessageMeta: any = null private wcdbGetMessageMeta: any = null
private wcdbGetContact: any = null private wcdbGetContact: any = null
@@ -57,6 +58,12 @@ export class WcdbCore {
private wcdbGetDbStatus: any = null private wcdbGetDbStatus: any = null
private wcdbGetVoiceData: any = null private wcdbGetVoiceData: any = null
private wcdbGetSnsTimeline: any = null private wcdbGetSnsTimeline: any = null
private wcdbGetSnsAnnualStats: any = null
private wcdbVerifyUser: any = null
private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null
private monitorPipeClient: any = null
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
private logTimer: NodeJS.Timeout | null = null private logTimer: NodeJS.Timeout | null = null
@@ -76,6 +83,80 @@ export class WcdbCore {
} }
} }
// 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false
}
try {
const result = this.wcdbStartMonitorPipe()
if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false
}
const net = require('net')
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor'
setTimeout(() => {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => {
this.writeLog('Monitor pipe connected')
})
let buffer = ''
this.monitorPipeClient.on('data', (data: Buffer) => {
buffer += data.toString('utf8')
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line)
callback(parsed.action || 'update', line)
} catch {
callback('update', line)
}
}
}
})
this.monitorPipeClient.on('error', (err: Error) => {
this.writeLog(`Monitor pipe error: ${err.message}`)
})
this.monitorPipeClient.on('close', () => {
this.writeLog('Monitor pipe closed')
this.monitorPipeClient = null
})
}, 100)
this.writeLog('Monitor started via named pipe IPC')
return true
} catch (e) {
console.error('startMonitor failed:', e)
return false
}
}
stopMonitor(): void {
if (this.monitorPipeClient) {
this.monitorPipeClient.destroy()
this.monitorPipeClient = null
}
if (this.wcdbStopMonitorPipe) {
this.wcdbStopMonitorPipe()
}
}
// 保留旧方法签名以兼容
setMonitor(callback: (type: string, json: string) => void): boolean {
return this.startMonitor(callback)
}
/** /**
* 获取 DLL 路径 * 获取 DLL 路径
*/ */
@@ -110,7 +191,7 @@ export class WcdbCore {
} }
private isLogEnabled(): boolean { private isLogEnabled(): boolean {
if (process.env.WEFLOW_WORKER === '1') return false // 移除 Worker 线程的日志禁用逻辑,允许在 Worker 中记录日志
if (process.env.WCDB_LOG_ENABLED === '1') return true if (process.env.WCDB_LOG_ENABLED === '1') return true
return this.logEnabled return this.logEnabled
} }
@@ -119,7 +200,7 @@ export class WcdbCore {
if (!force && !this.isLogEnabled()) return if (!force && !this.isLogEnabled()) return
const line = `[${new Date().toISOString()}] ${message}` const line = `[${new Date().toISOString()}] ${message}`
// 同时输出到控制台和文件 // 同时输出到控制台和文件
console.log('[WCDB]', message)
try { try {
const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd()
const dir = join(base, 'logs') const dir = join(base, 'logs')
@@ -259,24 +340,24 @@ export class WcdbCore {
let protectionOk = false let protectionOk = false
for (const resPath of resourcePaths) { for (const resPath of resourcePaths) {
try { try {
console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) //
protectionOk = this.wcdbInitProtection(resPath) protectionOk = this.wcdbInitProtection(resPath)
if (protectionOk) { if (protectionOk) {
console.log(`[WCDB] InitProtection 成功: ${resPath}`) //
break break
} }
} catch (e) { } catch (e) {
console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) // console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e)
} }
} }
if (!protectionOk) { if (!protectionOk) {
console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') // console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定')
this.writeLog('InitProtection 失败,继续运行') // this.writeLog('InitProtection 失败,继续运行')
// 不返回 false允许继续运行 // 不返回 false允许继续运行
} }
} catch (e) { } catch (e) {
console.warn('InitProtection symbol not found:', e) // console.warn('InitProtection symbol not found:', e)
} }
// 定义类型 // 定义类型
@@ -332,6 +413,13 @@ export class WcdbCore {
// wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json) // wcdb_status wcdb_get_group_members(wcdb_handle handle, const char* chatroom_id, char** out_json)
this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)') this.wcdbGetGroupMembers = this.lib.func('int32 wcdb_get_group_members(int64 handle, const char* chatroomId, _Out_ void** outJson)')
// wcdb_status wcdb_get_group_nicknames(wcdb_handle handle, const char* chatroom_id, char** out_json)
try {
this.wcdbGetGroupNicknames = this.lib.func('int32 wcdb_get_group_nicknames(int64 handle, const char* chatroomId, _Out_ void** outJson)')
} catch {
this.wcdbGetGroupNicknames = null
}
// wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json) // wcdb_status wcdb_get_message_tables(wcdb_handle handle, const char* session_id, char** out_json)
this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)') this.wcdbGetMessageTables = this.lib.func('int32 wcdb_get_message_tables(int64 handle, const char* sessionId, _Out_ void** outJson)')
@@ -368,6 +456,13 @@ export class WcdbCore {
this.wcdbGetAnnualReportExtras = null this.wcdbGetAnnualReportExtras = null
} }
// wcdb_status wcdb_get_logs(char** out_json)
try {
this.wcdbGetLogs = this.lib.func('int32 wcdb_get_logs(_Out_ void** outJson)')
} catch {
this.wcdbGetLogs = null
}
// wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json) // wcdb_status wcdb_get_group_stats(wcdb_handle handle, const char* chatroom_id, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try { try {
this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)') this.wcdbGetGroupStats = this.lib.func('int32 wcdb_get_group_stats(int64 handle, const char* chatroomId, int32 begin, int32 end, _Out_ void** outJson)')
@@ -430,6 +525,31 @@ export class WcdbCore {
this.wcdbGetSnsTimeline = null this.wcdbGetSnsTimeline = null
} }
// wcdb_status wcdb_get_sns_annual_stats(wcdb_handle handle, int32_t begin_timestamp, int32_t end_timestamp, char** out_json)
try {
this.wcdbGetSnsAnnualStats = this.lib.func('int32 wcdb_get_sns_annual_stats(int64 handle, int32 begin, int32 end, _Out_ void** outJson)')
} catch {
this.wcdbGetSnsAnnualStats = null
}
// Named pipe IPC for monitoring (replaces callback)
try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.writeLog('Monitor pipe functions loaded')
} catch (e) {
console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null
}
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
try {
this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)')
} catch {
this.wcdbVerifyUser = null
}
// 初始化 // 初始化
const initResult = this.wcdbInit() const initResult = this.wcdbInit()
if (initResult !== 0) { if (initResult !== 0) {
@@ -823,6 +943,37 @@ export class WcdbCore {
} }
} }
/**
* 获取指定时间之后的新消息
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error }
}
const cursor = openRes.cursor
try {
// 2. 获取批次
const fetchRes = await this.fetchMessageBatch(cursor)
if (!fetchRes.success) {
return { success: false, error: fetchRes.error }
}
return { success: true, messages: fetchRes.rows }
} finally {
// 3. 关闭游标
await this.closeMessageCursor(cursor)
}
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> { async getMessageCount(sessionId: string): Promise<{ success: boolean; count?: number; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -994,6 +1145,28 @@ export class WcdbCore {
} }
} }
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
if (!this.wcdbGetGroupNicknames) {
return { success: false, error: '当前 DLL 版本不支持获取群昵称接口' }
}
try {
const outPtr = [null as any]
const result = this.wcdbGetGroupNicknames(this.handle, chatroomId, outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取群昵称失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析群昵称失败' }
const nicknames = JSON.parse(jsonStr)
return { success: true, nicknames }
} catch (e) {
return { success: false, error: String(e) }
}
}
async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> { async getMessageTables(sessionId: string): Promise<{ success: boolean; tables?: any[]; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
@@ -1335,13 +1508,31 @@ export class WcdbCore {
} }
} }
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
if (!this.lib) return { success: false, error: 'DLL 未加载' }
if (!this.wcdbGetLogs) return { success: false, error: '接口未就绪' }
try {
const outPtr = [null as any]
const result = this.wcdbGetLogs(outPtr)
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `获取日志失败: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: '解析日志失败' }
return { success: true, logs: JSON.parse(jsonStr) }
} catch (e) {
return { success: false, error: String(e) }
}
}
async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> { async execQuery(kind: string, path: string | null, sql: string): Promise<{ success: boolean; rows?: any[]; error?: string }> {
if (!this.ensureReady()) { if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' } return { success: false, error: 'WCDB 未连接' }
} }
try { try {
if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
const outPtr = [null as any] const outPtr = [null as any]
const result = this.wcdbExecQuery(this.handle, kind, path, sql, outPtr) const result = this.wcdbExecQuery(this.handle, kind, path || '', sql, outPtr)
if (result !== 0 || !outPtr[0]) { if (result !== 0 || !outPtr[0]) {
return { success: false, error: `执行查询失败: ${result}` } return { success: false, error: `执行查询失败: ${result}` }
} }
@@ -1434,6 +1625,39 @@ export class WcdbCore {
} }
} }
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
if (!this.initialized) {
const initOk = await this.initialize()
if (!initOk) return { success: false, error: 'WCDB 初始化失败' }
}
if (!this.wcdbVerifyUser) {
return { success: false, error: 'Binding not found: VerifyUser' }
}
return new Promise((resolve) => {
try {
// Allocate buffer for result JSON
const maxLen = 1024
const outBuf = Buffer.alloc(maxLen)
// Call native function
const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0)
this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen)
// Parse result
const jsonStr = this.koffi.decode(outBuf, 'char', -1)
const result = JSON.parse(jsonStr)
resolve(result)
} catch (e) {
resolve({ success: false, error: String(e) })
}
})
}
async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> {
if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' }
if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' }
@@ -1461,4 +1685,29 @@ export class WcdbCore {
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
} }
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
if (!this.ensureReady()) {
return { success: false, error: 'WCDB 未连接' }
}
try {
if (!this.wcdbGetSnsAnnualStats) {
return { success: false, error: 'wcdbGetSnsAnnualStats 未找到' }
}
await new Promise(resolve => setImmediate(resolve))
const outPtr = [null as any]
const result = this.wcdbGetSnsAnnualStats(this.handle, beginTimestamp, endTimestamp, outPtr)
await new Promise(resolve => setImmediate(resolve))
if (result !== 0 || !outPtr[0]) {
return { success: false, error: `getSnsAnnualStats failed: ${result}` }
}
const jsonStr = this.decodeJsonPtr(outPtr[0])
if (!jsonStr) return { success: false, error: 'Failed to decode JSON' }
return { success: true, data: JSON.parse(jsonStr) }
} catch (e) {
console.error('getSnsAnnualStats 异常:', e)
return { success: false, error: String(e) }
}
}
} }

View File

@@ -23,6 +23,7 @@ export class WcdbService {
private resourcesPath: string | null = null private resourcesPath: string | null = null
private userDataPath: string | null = null private userDataPath: string | null = null
private logEnabled = false private logEnabled = false
private monitorListener: ((type: string, json: string) => void) | null = null
constructor() { constructor() {
this.initWorker() this.initWorker()
@@ -47,8 +48,16 @@ export class WcdbService {
try { try {
this.worker = new Worker(finalPath) this.worker = new Worker(finalPath)
this.worker.on('message', (msg: WorkerMessage) => { this.worker.on('message', (msg: any) => {
const { id, result, error } = msg const { id, result, error, type, payload } = msg
if (type === 'monitor') {
if (this.monitorListener) {
this.monitorListener(payload.type, payload.json)
}
return
}
const p = this.pending.get(id) const p = this.pending.get(id)
if (p) { if (p) {
this.pending.delete(id) this.pending.delete(id)
@@ -122,6 +131,15 @@ export class WcdbService {
this.callWorker('setLogEnabled', { enabled }).catch(() => { }) this.callWorker('setLogEnabled', { enabled }).catch(() => { })
} }
/**
* 设置数据库监控回调
*/
setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback;
// Notify worker to enable monitor
this.callWorker('setMonitor').catch(() => { });
}
/** /**
* 检查服务是否就绪 * 检查服务是否就绪
*/ */
@@ -187,6 +205,13 @@ export class WcdbService {
return this.callWorker('getMessages', { sessionId, limit, offset }) return this.callWorker('getMessages', { sessionId, limit, offset })
} }
/**
* 获取新消息(增量刷新)
*/
async getNewMessages(sessionId: string, minTime: number, limit: number = 1000): Promise<{ success: boolean; messages?: any[]; error?: string }> {
return this.callWorker('getNewMessages', { sessionId, minTime, limit })
}
/** /**
* 获取消息总数 * 获取消息总数
*/ */
@@ -229,6 +254,11 @@ export class WcdbService {
return this.callWorker('getGroupMembers', { chatroomId }) return this.callWorker('getGroupMembers', { chatroomId })
} }
// 获取群成员群名片昵称
async getGroupNicknames(chatroomId: string): Promise<{ success: boolean; nicknames?: Record<string, string>; error?: string }> {
return this.callWorker('getGroupNicknames', { chatroomId })
}
/** /**
* 获取消息表列表 * 获取消息表列表
*/ */
@@ -369,6 +399,27 @@ export class WcdbService {
return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime })
} }
/**
* 获取朋友圈年度统计
*/
async getSnsAnnualStats(beginTimestamp: number, endTimestamp: number): Promise<{ success: boolean; data?: any; error?: string }> {
return this.callWorker('getSnsAnnualStats', { beginTimestamp, endTimestamp })
}
/**
* 获取 DLL 内部日志
*/
async getLogs(): Promise<{ success: boolean; logs?: string[]; error?: string }> {
return this.callWorker('getLogs')
}
/**
* 验证 Windows Hello
*/
async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> {
return this.callWorker('verifyUser', { message, hwnd })
}
} }
export const wcdbService = new WcdbService() export const wcdbService = new WcdbService()

View File

@@ -0,0 +1,32 @@
import { wcdbService } from './wcdbService'
import { BrowserWindow } from 'electron'
export class WindowsHelloService {
private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null
/**
* 验证 Windows Hello
* @param message 提示信息
*/
async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> {
// Prevent concurrent verification requests
if (this.verificationPromise) {
return this.verificationPromise
}
// 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口
const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0]
const hwndBuffer = window?.getNativeWindowHandle()
// Convert buffer to int string for transport
const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined
this.verificationPromise = wcdbService.verifyUser(message, hwndStr)
.finally(() => {
this.verificationPromise = null
})
return this.verificationPromise
}
}
export const windowsHelloService = new WindowsHelloService()

View File

@@ -80,17 +80,17 @@ function isLanguageAllowed(result: any, allowedLanguages: string[]): boolean {
} }
const langTag = result.lang const langTag = result.lang
console.log('[TranscribeWorker] 检测到语言标记:', langTag)
// 检查是否在允许的语言列表中 // 检查是否在允许的语言列表中
for (const lang of allowedLanguages) { for (const lang of allowedLanguages) {
if (LANGUAGE_TAGS[lang] === langTag) { if (LANGUAGE_TAGS[lang] === langTag) {
console.log('[TranscribeWorker] 语言匹配,允许:', lang)
return true return true
} }
} }
console.log('[TranscribeWorker] 语言不在白名单中,过滤掉')
return false return false
} }
@@ -117,7 +117,7 @@ async function run() {
allowedLanguages = ['zh'] allowedLanguages = ['zh']
} }
console.log('[TranscribeWorker] 使用的语言白名单:', allowedLanguages)
// 1. 初始化识别器 (SenseVoiceSmall) // 1. 初始化识别器 (SenseVoiceSmall)
const recognizerConfig = { const recognizerConfig = {
@@ -145,15 +145,15 @@ async function run() {
recognizer.decode(stream) recognizer.decode(stream)
const result = recognizer.getResult(stream) const result = recognizer.getResult(stream)
console.log('[TranscribeWorker] 识别完成 - 结果对象:', JSON.stringify(result, null, 2))
// 3. 检查语言是否在白名单中 // 3. 检查语言是否在白名单中
if (isLanguageAllowed(result, allowedLanguages)) { if (isLanguageAllowed(result, allowedLanguages)) {
const processedText = richTranscribePostProcess(result.text) const processedText = richTranscribePostProcess(result.text)
console.log('[TranscribeWorker] 语言匹配,返回文本:', processedText)
parentPort.postMessage({ type: 'final', text: processedText }) parentPort.postMessage({ type: 'final', text: processedText })
} else { } else {
console.log('[TranscribeWorker] 语言不匹配,返回空文本')
parentPort.postMessage({ type: 'final', text: '' }) parentPort.postMessage({ type: 'final', text: '' })
} }

View File

@@ -19,6 +19,16 @@ if (parentPort) {
core.setLogEnabled(payload.enabled) core.setLogEnabled(payload.enabled)
result = { success: true } result = { success: true }
break break
case 'setMonitor':
core.setMonitor((type, json) => {
parentPort!.postMessage({
id: -1,
type: 'monitor',
payload: { type, json }
})
})
result = { success: true }
break
case 'testConnection': case 'testConnection':
result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid) result = await core.testConnection(payload.dbPath, payload.hexKey, payload.wxid)
break break
@@ -38,6 +48,9 @@ if (parentPort) {
case 'getMessages': case 'getMessages':
result = await core.getMessages(payload.sessionId, payload.limit, payload.offset) result = await core.getMessages(payload.sessionId, payload.limit, payload.offset)
break break
case 'getNewMessages':
result = await core.getNewMessages(payload.sessionId, payload.minTime, payload.limit)
break
case 'getMessageCount': case 'getMessageCount':
result = await core.getMessageCount(payload.sessionId) result = await core.getMessageCount(payload.sessionId)
break break
@@ -56,6 +69,9 @@ if (parentPort) {
case 'getGroupMembers': case 'getGroupMembers':
result = await core.getGroupMembers(payload.chatroomId) result = await core.getGroupMembers(payload.chatroomId)
break break
case 'getGroupNicknames':
result = await core.getGroupNicknames(payload.chatroomId)
break
case 'getMessageTables': case 'getMessageTables':
result = await core.getMessageTables(payload.sessionId) result = await core.getMessageTables(payload.sessionId)
break break
@@ -119,6 +135,15 @@ if (parentPort) {
case 'getSnsTimeline': case 'getSnsTimeline':
result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime)
break break
case 'getSnsAnnualStats':
result = await core.getSnsAnnualStats(payload.beginTimestamp, payload.endTimestamp)
break
case 'getLogs':
result = await core.getLogs()
break
case 'verifyUser':
result = await core.verifyUser(payload.message, payload.hwnd)
break
default: default:
result = { success: false, error: `Unknown method: ${type}` } result = { success: false, error: `Unknown method: ${type}` }
} }

View File

@@ -0,0 +1,200 @@
import { BrowserWindow, ipcMain, screen } from 'electron'
import { join } from 'path'
import { ConfigService } from '../services/config'
let notificationWindow: BrowserWindow | null = null
let closeTimer: NodeJS.Timeout | null = null
export function createNotificationWindow() {
if (notificationWindow && !notificationWindow.isDestroyed()) {
return notificationWindow
}
const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev
? join(__dirname, '../../public/icon.ico')
: join(process.resourcesPath, 'icon.ico')
console.log('[NotificationWindow] Creating window...')
const width = 344
const height = 114
// Update default creation size
notificationWindow = new BrowserWindow({
width: width,
height: height,
type: 'toolbar', // 有助于在某些操作系统上保持置顶
frame: false,
transparent: true,
resizable: false,
show: false,
alwaysOnTop: true,
skipTaskbar: true,
focusable: false, // 不抢占焦点
icon: iconPath,
webPreferences: {
preload: join(__dirname, 'preload.js'), // FIX: Use correct relative path (same dir in dist)
contextIsolation: true,
nodeIntegration: false,
// devTools: true // Enable DevTools
}
})
// notificationWindow.webContents.openDevTools({ mode: 'detach' }) // DEBUG: Force Open DevTools
notificationWindow.setIgnoreMouseEvents(true, { forward: true }) // 初始点击穿透
// 处理鼠标事件 (如果需要从渲染进程转发,但目前特定区域处理?)
// 实际上,我们希望窗口可点击。
// 我们将在显示时将忽略鼠标事件设为 false。
const loadUrl = isDev
? `${process.env.VITE_DEV_SERVER_URL}#/notification-window`
: `file://${join(__dirname, '../dist/index.html')}#/notification-window`
console.log('[NotificationWindow] Loading URL:', loadUrl)
notificationWindow.loadURL(loadUrl)
notificationWindow.on('closed', () => {
notificationWindow = null
})
return notificationWindow
}
export async function showNotification(data: any) {
// 先检查配置
const config = ConfigService.getInstance()
const enabled = await config.get('notificationEnabled')
if (enabled === false) return // 默认为 true
// 检查会话过滤
const filterMode = config.get('notificationFilterMode') || 'all'
const filterList = config.get('notificationFilterList') || []
const sessionId = data.sessionId
if (sessionId && filterMode !== 'all' && filterList.length > 0) {
const isInList = filterList.includes(sessionId)
if (filterMode === 'whitelist' && !isInList) {
// 白名单模式:不在列表中则不显示
console.log('[NotificationWindow] Filtered by whitelist:', sessionId)
return
}
if (filterMode === 'blacklist' && isInList) {
// 黑名单模式:在列表中则不显示
console.log('[NotificationWindow] Filtered by blacklist:', sessionId)
return
}
}
let win = notificationWindow
if (!win || win.isDestroyed()) {
win = createNotificationWindow()
}
if (!win) return
// 确保加载完成
if (win.webContents.isLoading()) {
win.once('ready-to-show', () => {
showAndSend(win!, data)
})
} else {
showAndSend(win, data)
}
}
let lastNotificationData: any = null
async function showAndSend(win: BrowserWindow, data: any) {
lastNotificationData = data
const config = ConfigService.getInstance()
const position = (await config.get('notificationPosition')) || 'top-right'
// 更新位置
const { width: screenWidth, height: screenHeight } = screen.getPrimaryDisplay().workAreaSize
const winWidth = 344
const winHeight = 114
const padding = 20
let x = 0
let y = 0
switch (position) {
case 'top-right':
x = screenWidth - winWidth - padding
y = padding
break
case 'bottom-right':
x = screenWidth - winWidth - padding
y = screenHeight - winHeight - padding
break
case 'top-left':
x = padding
y = padding
break
case 'bottom-left':
x = padding
y = screenHeight - winHeight - padding
break
}
win.setPosition(Math.floor(x), Math.floor(y))
win.setSize(winWidth, winHeight) // 确保尺寸
// 设为可交互
win.setIgnoreMouseEvents(false)
win.showInactive() // 显示但不聚焦
win.setAlwaysOnTop(true, 'screen-saver') // 最高层级
win.webContents.send('notification:show', data)
// 自动关闭计时器通常由渲染进程管理
// 渲染进程发送 'notification:close' 来隐藏窗口
}
export function registerNotificationHandlers() {
ipcMain.handle('notification:show', (_, data) => {
showNotification(data)
})
ipcMain.handle('notification:close', () => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
notificationWindow.hide()
notificationWindow.setIgnoreMouseEvents(true, { forward: true })
}
})
// Handle renderer ready event (fix race condition)
ipcMain.on('notification:ready', (event) => {
console.log('[NotificationWindow] Renderer ready, checking cached data')
if (lastNotificationData && notificationWindow && !notificationWindow.isDestroyed()) {
console.log('[NotificationWindow] Re-sending cached data')
notificationWindow.webContents.send('notification:show', lastNotificationData)
}
})
// Handle resize request from renderer
ipcMain.on('notification:resize', (event, { width, height }) => {
if (notificationWindow && !notificationWindow.isDestroyed()) {
// Enforce max-height if needed, or trust renderer
// Ensure it doesn't go off screen bottom?
// Logic in showAndSend handles position, but we need to keep anchor point (top-right usually).
// If we resize, we should re-calculate position to keep it anchored?
// Actually, setSize changes size. If it's top-right, x/y stays same -> window grows down. That's fine for top-right.
// If bottom-right, growing down pushes it off screen.
// Simple version: just setSize. For V1 we assume Top-Right.
// But wait, the config supports bottom-right.
// We can re-call setPosition or just let it be.
// If bottom-right, y needs to prevent overflow.
// Ideally we get current config position
const bounds = notificationWindow.getBounds()
// Check if we need to adjust Y?
// For now, let's just set the size as requested.
notificationWindow.setSize(Math.round(width), Math.round(height))
}
})
// 'notification-clicked' 在 main.ts 中处理 (导航)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 185 KiB

15
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.5.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.5.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"better-sqlite3": "^12.5.0", "better-sqlite3": "^12.5.0",
@@ -25,6 +25,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
@@ -8044,6 +8045,16 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/react-virtuoso": {
"version": "4.18.1",
"resolved": "https://registry.npmmirror.com/react-virtuoso/-/react-virtuoso-4.18.1.tgz",
"integrity": "sha512-KF474cDwaSb9+SJ380xruBB4P+yGWcVkcu26HtMqYNMTYlYbrNy8vqMkE+GpAApPPufJqgOLMoWMFG/3pJMXUA==",
"license": "MIT",
"peerDependencies": {
"react": ">=16 || >=17 || >= 18 || >= 19",
"react-dom": ">=16 || >=17 || >= 18 || >=19"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",

View File

@@ -1,13 +1,17 @@
{ {
"name": "weflow", "name": "weflow",
"version": "1.4.1", "version": "1.5.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
"repository": {
"type": "git",
"url": "https://github.com/hicccc77/WeFlow"
},
"//": "二改不应改变此处的作者与应用信息", "//": "二改不应改变此处的作者与应用信息",
"scripts": { "scripts": {
"postinstall": "echo 'No native modules to rebuild'", "postinstall": "echo 'No native modules to rebuild'",
"rebuild": "echo 'No native modules to rebuild'", "rebuild": "electron-rebuild",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build && electron-builder", "build": "tsc && vite build && electron-builder",
"preview": "vite preview", "preview": "vite preview",
@@ -31,6 +35,7 @@
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"react-virtuoso": "^4.18.1",
"sherpa-onnx-node": "^1.10.38", "sherpa-onnx-node": "^1.10.38",
"silk-wasm": "^3.7.1", "silk-wasm": "^3.7.1",
"wechat-emojis": "^1.0.2", "wechat-emojis": "^1.0.2",
@@ -55,6 +60,8 @@
"appId": "com.WeFlow.app", "appId": "com.WeFlow.app",
"publish": { "publish": {
"provider": "github", "provider": "github",
"owner": "hicccc77",
"repo": "WeFlow",
"releaseType": "release" "releaseType": "release"
}, },
"productName": "WeFlow", "productName": "WeFlow",

Binary file not shown.

View File

@@ -10,15 +10,18 @@ import AnalyticsPage from './pages/AnalyticsPage'
import AnalyticsWelcomePage from './pages/AnalyticsWelcomePage' 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 DualReportPage from './pages/DualReportPage'
import DualReportWindow from './pages/DualReportWindow'
import AgreementPage from './pages/AgreementPage' import AgreementPage from './pages/AgreementPage'
import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
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 VideoWindow from './pages/VideoWindow' import VideoWindow from './pages/VideoWindow'
import ImageWindow from './pages/ImageWindow'
import SnsPage from './pages/SnsPage' import SnsPage from './pages/SnsPage'
import ContactsPage from './pages/ContactsPage' import ContactsPage from './pages/ContactsPage'
import ChatHistoryPage from './pages/ChatHistoryPage' import ChatHistoryPage from './pages/ChatHistoryPage'
import NotificationWindow from './pages/NotificationWindow'
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'
@@ -29,10 +32,12 @@ import './App.scss'
import UpdateDialog from './components/UpdateDialog' import UpdateDialog from './components/UpdateDialog'
import UpdateProgressCapsule from './components/UpdateProgressCapsule' import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen' import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const { const {
setDbConnected, setDbConnected,
updateInfo, updateInfo,
@@ -43,7 +48,9 @@ function App() {
setDownloadProgress, setDownloadProgress,
showUpdateDialog, showUpdateDialog,
setShowUpdateDialog, setShowUpdateDialog,
setUpdateError setUpdateError,
isLocked,
setLocked
} = useAppStore() } = useAppStore()
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
@@ -51,11 +58,14 @@ function App() {
const isOnboardingWindow = location.pathname === '/onboarding-window' const isOnboardingWindow = location.pathname === '/onboarding-window'
const isVideoPlayerWindow = location.pathname === '/video-player-window' const isVideoPlayerWindow = location.pathname === '/video-player-window'
const isChatHistoryWindow = location.pathname.startsWith('/chat-history/') const isChatHistoryWindow = location.pathname.startsWith('/chat-history/')
const isNotificationWindow = location.pathname === '/notification-window'
const [themeHydrated, setThemeHydrated] = useState(false) const [themeHydrated, setThemeHydrated] = useState(false)
// 锁定状态 // 锁定状态
const [isLocked, setIsLocked] = useState(false) // const [isLocked, setIsLocked] = useState(false) // Moved to store
const [lockAvatar, setLockAvatar] = useState<string | undefined>(undefined) const [lockAvatar, setLockAvatar] = useState<string | undefined>(
localStorage.getItem('app_lock_avatar') || undefined
)
const [lockUseHello, setLockUseHello] = useState(false) const [lockUseHello, setLockUseHello] = useState(false)
// 协议同意状态 // 协议同意状态
@@ -68,7 +78,7 @@ function App() {
const body = document.body const body = document.body
const appRoot = document.getElementById('app') const appRoot = document.getElementById('app')
if (isOnboardingWindow) { if (isOnboardingWindow || isNotificationWindow) {
root.style.background = 'transparent' root.style.background = 'transparent'
body.style.background = 'transparent' body.style.background = 'transparent'
body.style.overflow = 'hidden' body.style.overflow = 'hidden'
@@ -94,10 +104,10 @@ function App() {
// 更新窗口控件颜色以适配主题 // 更新窗口控件颜色以适配主题
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a' const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a'
if (!isOnboardingWindow) { if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor }) window.electronAPI.window.setTitleBarOverlay({ symbolColor })
} }
}, [currentTheme, themeMode, isOnboardingWindow]) }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
// 读取已保存的主题设置 // 读取已保存的主题设置
useEffect(() => { useEffect(() => {
@@ -167,21 +177,23 @@ function App() {
// 监听启动时的更新通知 // 监听启动时的更新通知
useEffect(() => { useEffect(() => {
const removeUpdateListener = window.electronAPI.app.onUpdateAvailable?.((info: any) => { if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时自动打开更新弹窗 // 发现新版本时自动打开更新弹窗
if (info) { if (info) {
setUpdateInfo({ ...info, hasUpdate: true }) setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true) setShowUpdateDialog(true)
} }
}) })
const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
setDownloadProgress(progress) setDownloadProgress(progress)
}) })
return () => { return () => {
removeUpdateListener?.() removeUpdateListener?.()
removeProgressListener?.() removeProgressListener?.()
} }
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog]) }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
const handleUpdateNow = async () => { const handleUpdateNow = async () => {
setShowUpdateDialog(false) setShowUpdateDialog(false)
@@ -198,6 +210,18 @@ function App() {
} }
} }
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
} catch (e: any) {
console.error('忽略更新失败:', e)
}
}
const dismissUpdate = () => { const dismissUpdate = () => {
setUpdateInfo(null) setUpdateInfo(null)
} }
@@ -224,18 +248,18 @@ function App() {
if (!onboardingDone) { if (!onboardingDone) {
await configService.setOnboardingDone(true) await configService.setOnboardingDone(true)
} }
console.log('检测到已保存的配置,正在自动连接...')
const result = await window.electronAPI.chat.connect() const result = await window.electronAPI.chat.connect()
if (result.success) { if (result.success) {
console.log('自动连接成功')
setDbConnected(true, dbPath) setDbConnected(true, dbPath)
// 如果当前在欢迎页,跳转到首页 // 如果当前在欢迎页,跳转到首页
if (window.location.hash === '#/' || window.location.hash === '') { if (window.location.hash === '#/' || window.location.hash === '') {
navigate('/home') navigate('/home')
} }
} else { } else {
console.log('自动连接失败:', result.error)
// 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户 // 如果错误信息包含 VC++ 或 DLL 相关内容,不清除配置,只提示用户
// 其他错误可能需要重新配置 // 其他错误可能需要重新配置
const errorMsg = result.error || '' const errorMsg = result.error || ''
@@ -271,12 +295,13 @@ function App() {
if (enabled) { if (enabled) {
setLockUseHello(useHello) setLockUseHello(useHello)
setIsLocked(true) setLocked(true)
// 尝试获取头像 // 尝试获取头像
try { try {
const result = await window.electronAPI.chat.getMyAvatarUrl() const result = await window.electronAPI.chat.getMyAvatarUrl()
if (result && result.success && result.avatarUrl) { if (result && result.success && result.avatarUrl) {
setLockAvatar(result.avatarUrl) setLockAvatar(result.avatarUrl)
localStorage.setItem('app_lock_avatar', result.avatarUrl)
} }
} catch (e) { } catch (e) {
console.error('获取锁屏头像失败', e) console.error('获取锁屏头像失败', e)
@@ -300,17 +325,28 @@ function App() {
return <VideoWindow /> return <VideoWindow />
} }
// 独立图片查看窗口
const isImageViewerWindow = location.pathname === '/image-viewer-window'
if (isImageViewerWindow) {
return <ImageWindow />
}
// 独立聊天记录窗口 // 独立聊天记录窗口
if (isChatHistoryWindow) { if (isChatHistoryWindow) {
return <ChatHistoryPage /> return <ChatHistoryPage />
} }
// 独立通知窗口
if (isNotificationWindow) {
return <NotificationWindow />
}
// 主窗口 - 完整布局 // 主窗口 - 完整布局
return ( return (
<div className="app-container"> <div className="app-container">
{isLocked && ( {isLocked && (
<LockScreen <LockScreen
onUnlock={() => setIsLocked(false)} onUnlock={() => setLocked(false)}
avatar={lockAvatar} avatar={lockAvatar}
useHello={lockUseHello} useHello={lockUseHello}
/> />
@@ -320,6 +356,9 @@ function App() {
{/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */} {/* 全局悬浮进度胶囊 (处理:新版本提示、下载进度、错误提示) */}
<UpdateProgressCapsule /> <UpdateProgressCapsule />
{/* 全局会话监听与通知 */}
<GlobalSessionMonitor />
{/* 用户协议弹窗 */} {/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && ( {showAgreement && !agreementLoading && (
<div className="agreement-overlay"> <div className="agreement-overlay">
@@ -377,6 +416,7 @@ function App() {
updateInfo={updateInfo} updateInfo={updateInfo}
onClose={() => setShowUpdateDialog(false)} onClose={() => setShowUpdateDialog(false)}
onUpdate={handleUpdateNow} onUpdate={handleUpdateNow}
onIgnore={handleIgnoreUpdate}
isDownloading={isDownloading} isDownloading={isDownloading}
progress={downloadProgress} progress={downloadProgress}
/> />
@@ -394,7 +434,9 @@ function App() {
<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 />} />
<Route path="/data-management" element={<DataManagementPage />} /> <Route path="/dual-report" element={<DualReportPage />} />
<Route path="/dual-report/view" element={<DualReportWindow />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/export" element={<ExportPage />} /> <Route path="/export" element={<ExportPage />} />
<Route path="/sns" element={<SnsPage />} /> <Route path="/sns" element={<SnsPage />} />

View File

@@ -0,0 +1,258 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession } from '../types/models'
import { useNavigate } from 'react-router-dom'
export function GlobalSessionMonitor() {
const navigate = useNavigate()
const {
sessions,
setSessions,
currentSessionId,
appendMessages,
messages
} = useChatStore()
const sessionsRef = useRef(sessions)
// 保持 ref 同步
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
// 去重辅助函数:获取消息 key
const getMessageKey = (msg: any) => {
if (msg.localId && msg.localId > 0) return `l:${msg.localId}`
return `t:${msg.createTime}:${msg.sortSeq || 0}:${msg.serverId || 0}`
}
// 处理数据库变更
useEffect(() => {
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
try {
const payload = JSON.parse(data.json)
const tableName = payload.table
// 只关注 Session 表
if (tableName === 'Session' || tableName === 'session') {
refreshSessions()
}
} catch (e) {
console.error('解析数据库变更失败:', e)
}
}
if (window.electronAPI.chat.onWcdbChange) {
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
return () => {
removeListener()
}
}
return () => { }
}, []) // 空依赖数组 - 主要是静态的
const refreshSessions = async () => {
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions && Array.isArray(result.sessions)) {
const newSessions = result.sessions as ChatSession[]
const oldSessions = sessionsRef.current
// 1. 检测变更并通知
checkForNewMessages(oldSessions, newSessions)
// 2. 更新 store
setSessions(newSessions)
// 3. 如果在活跃会话中,增量刷新消息
const currentId = useChatStore.getState().currentSessionId
if (currentId) {
const currentSessionNew = newSessions.find(s => s.username === currentId)
const currentSessionOld = oldSessions.find(s => s.username === currentId)
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
void handleActiveSessionRefresh(currentId)
}
}
}
} catch (e) {
console.error('全局会话刷新失败:', e)
}
}
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
for (const newSession of newSessions) {
const oldSession = oldMap.get(newSession.username)
// 条件: 新会话或时间戳更新
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件
// 1. 群聊过滤自己发送的消息
if (newSession.username.includes('@chatroom')) {
// 如果是自己发的消息,不弹通知
// 注意lastMsgSender 需要后端支持返回
// 使用宽松比较以处理 wxid_ 前缀差异
if (newSession.lastMsgSender && newSession.selfWxid) {
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
const self = newSession.selfWxid.replace(/^wxid_/, '');
// 使用主进程日志打印,方便用户查看
const debugInfo = {
type: 'NotificationFilter',
username: newSession.username,
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid,
senderClean: sender,
selfClean: self,
match: sender === self
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(debugInfo);
} else {
console.log('[NotificationFilter]', debugInfo);
}
if (sender === self) {
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
} else {
console.log('[NotificationFilter] Filtered own message');
}
continue;
}
} else {
const missingInfo = {
type: 'NotificationFilter Missing info',
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(missingInfo);
} else {
console.log('[NotificationFilter] Missing info:', missingInfo);
}
}
}
let title = newSession.displayName || newSession.username
let avatarUrl = newSession.avatarUrl
let content = newSession.summary || '[新消息]'
if (newSession.username.includes('@chatroom')) {
// 1. 群聊过滤自己发送的消息
// 辅助函数:清理 wxid 后缀 (如 _8602)
const cleanWxid = (id: string) => {
if (!id) return '';
const trimmed = id.trim();
// 仅移除末尾的 _xxxx (4位字母数字)
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
return suffixMatch ? suffixMatch[1] : trimmed;
}
if (newSession.lastMsgSender && newSession.selfWxid) {
const senderClean = cleanWxid(newSession.lastMsgSender);
const selfClean = cleanWxid(newSession.selfWxid);
const match = senderClean === selfClean;
if (match) {
continue;
}
}
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
// 标题保持为群聊名称 (title 变量)
if (newSession.lastSenderDisplayName) {
content = `${newSession.lastSenderDisplayName}: ${content}`
}
}
// 修复 "Random User" 的逻辑 (缺少具体信息)
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
if (needsEnrichment && newSession.username) {
try {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickname) {
title = contact.remark || contact.nickname
}
if (contact.avatarUrl) {
avatarUrl = contact.avatarUrl
}
} else {
// 如果不在缓存/数据库中
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
if (enrichResult.success && enrichResult.contacts) {
const enrichedContact = enrichResult.contacts[newSession.username]
if (enrichedContact) {
if (enrichedContact.displayName) {
title = enrichedContact.displayName
}
if (enrichedContact.avatarUrl) {
avatarUrl = enrichedContact.avatarUrl
}
}
}
// 如果仍然没有有效名称,再尝试一次获取
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickname || title
avatarUrl = retried.avatarUrl || avatarUrl
}
}
}
} catch (e) {
console.warn('获取通知的联系人信息失败', e)
}
}
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
// 群聊例外,因为群聊 username 包含 @chatroom
const isGroupChat = newSession.username.includes('@chatroom')
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
if (isWxidTitle && !isGroupChat) {
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
continue
}
// 调用 IPC 以显示独立窗口通知
window.electronAPI.notification?.show({
title: title,
content: content,
avatarUrl: avatarUrl,
sessionId: newSession.username
})
// 我们不再为 Toast 设置本地状态
}
}
}
const handleActiveSessionRefresh = async (sessionId: string) => {
// 从 ChatPage 复制/调整的逻辑,以保持集中
const state = useChatStore.getState()
const lastMsg = state.messages[state.messages.length - 1]
const minTime = lastMsg?.createTime || 0
try {
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
if (result.success && result.messages && result.messages.length > 0) {
appendMessages(result.messages, false) // 追加到末尾
}
} catch (e) {
console.warn('后台活跃会话刷新失败:', e)
}
}
// 此组件不再渲染 UI
return null
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config' import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react' import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss' import './LockScreen.scss'
interface LockScreenProps { interface LockScreenProps {
@@ -63,18 +63,6 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
setShowHello(true) setShowHello(true)
// 立即执行验证 (0延迟) // 立即执行验证 (0延迟)
verifyHello() verifyHello()
// 后台再次确认可用性,如果其实不可用,再隐藏?
// 或者信任用户的配置。为了速度,我们优先信任配置。
if (window.PublicKeyCredential) {
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
.then(available => {
if (!available) {
// 如果系统报告不支持,但配置开了,我们可能需要提示?
// 暂时保持开启状态,反正 verifyHello 会报错
}
})
}
} }
} catch (e) { } catch (e) {
console.error('Quick start hello failed', e) console.error('Quick start hello failed', e)
@@ -84,63 +72,32 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
const verifyHello = async () => { const verifyHello = async () => {
if (isVerifying || isUnlocked) return if (isVerifying || isUnlocked) return
// 取消之前的请求(如果有)
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
const abortController = new AbortController()
abortControllerRef.current = abortController
setIsVerifying(true) setIsVerifying(true)
setError('') setError('')
try { try {
const challenge = new Uint8Array(32) const result = await window.electronAPI.auth.hello()
window.crypto.getRandomValues(challenge)
const rpId = 'localhost' if (result.success) {
const credential = await navigator.credentials.get({
publicKey: {
challenge,
rpId,
userVerification: 'required',
},
signal: abortController.signal
})
if (credential) {
handleUnlock() handleUnlock()
} else {
console.error('Hello verification failed:', result.error)
setError(result.error || '验证失败')
} }
} catch (e: any) { } catch (e: any) {
if (e.name === 'AbortError') {
console.log('Hello verification aborted')
return
}
if (e.name === 'NotAllowedError') {
console.log('User cancelled Hello verification')
} else {
console.error('Hello verification error:', e) console.error('Hello verification error:', e)
// 仅在非手动取消时显示错误 setError(`验证失败: ${e.message || String(e)}`)
if (e.name !== 'AbortError') {
setError(`验证失败: ${e.message || e.name}`)
}
}
} finally { } finally {
if (!abortController.signal.aborted) {
setIsVerifying(false) setIsVerifying(false)
} }
} }
}
const handlePasswordSubmit = async (e?: React.FormEvent) => { const handlePasswordSubmit = async (e?: React.FormEvent) => {
e?.preventDefault() e?.preventDefault()
if (!password || isUnlocked) return if (!password || isUnlocked) return
// 如果正在进行 Hello 验证,取消 // 如果正在进行 Hello 验证,它会自动失败或被取代UI上不用特意取消
if (abortControllerRef.current) { // 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
abortControllerRef.current.abort()
abortControllerRef.current = null
}
// 不再检查 isVerifying因为我们允许打断 Hello // 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true) setIsVerifying(true)

View File

@@ -0,0 +1,200 @@
.notification-toast-container {
position: fixed;
z-index: 9999;
width: 320px;
background: var(--bg-secondary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95);
pointer-events: none; // Allow clicking through when hidden
&.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
&.static {
position: relative !important;
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
height: auto !important; // Fits content
min-height: 0;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
margin: 2px !important; // 2px centered margin
border-radius: 12px !important; // Rounded corners
// Disable backdrop filter
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// Ensure background is solid
background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff);
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light, rgba(255, 255, 255, 0.1));
display: flex;
padding: 16px;
padding-right: 32px; // Make space for close button
box-sizing: border-box;
// Force close button to be visible but transparent background
.notification-close {
opacity: 1 !important;
top: 12px;
right: 12px;
background: transparent !important; // Transparent per user request
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
}
}
.notification-time {
top: 24px; // Match padding
right: 40px; // Left of close button (12px + 20px + 8px)
}
}
// Position variants
&.bottom-right {
bottom: 24px;
right: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-right {
top: 24px;
right: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.bottom-left {
bottom: 24px;
left: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-left {
top: 24px;
left: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}
.notification-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.notification-avatar {
flex-shrink: 0;
}
.notification-text {
flex: 1;
min-width: 0;
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.notification-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // 允许缩放
flex: 1; // 占据剩余空间
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
margin-right: 60px; // Make space for absolute time + close button
}
.notification-time {
font-size: 12px;
color: var(--text-tertiary);
position: absolute;
top: 16px;
right: 36px; // Left of close button (8px + 20px + 8px)
font-variant-numeric: tabular-nums;
}
}
.notification-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
}
.notification-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
&:hover .notification-close {
opacity: 1;
}
}

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { Avatar } from './Avatar'
import './NotificationToast.scss'
export interface NotificationData {
id: string
sessionId: string
avatarUrl?: string
title: string
content: string
timestamp: number
}
interface NotificationToastProps {
data: NotificationData | null
onClose: () => void
onClick: (sessionId: string) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
isStatic?: boolean
initialVisible?: boolean
}
export function NotificationToast({
data,
onClose,
onClick,
duration = 5000,
position = 'top-right',
isStatic = false,
initialVisible = false
}: NotificationToastProps) {
const [isVisible, setIsVisible] = useState(initialVisible)
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
useEffect(() => {
if (data) {
setCurrentData(data)
setIsVisible(true)
const timer = setTimeout(() => {
setIsVisible(false)
// clean up data after animation
setTimeout(onClose, 300)
}, duration)
return () => clearTimeout(timer)
} else {
setIsVisible(false)
}
}, [data, duration, onClose])
if (!currentData) return null
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
setIsVisible(false)
setTimeout(onClose, 300)
}
const handleClick = () => {
setIsVisible(false)
setTimeout(() => {
onClose()
onClick(currentData.sessionId)
}, 300)
}
const content = (
<div
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
onClick={handleClick}
>
<div className="notification-content">
<div className="notification-avatar">
<Avatar
src={currentData.avatarUrl}
name={currentData.title}
size={40}
/>
</div>
<div className="notification-text">
<div className="notification-header">
<span className="notification-title">{currentData.title}</span>
<span className="notification-time">
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="notification-body">
{currentData.content}
</div>
</div>
<button className="notification-close" onClick={handleClose}>
<X size={14} />
</button>
</div>
</div>
)
if (isStatic) {
return content
}
// Portal to document.body to ensure it's on top
return createPortal(content, document.body)
}

View File

@@ -6,8 +6,7 @@ interface RouteGuardProps {
children: React.ReactNode children: React.ReactNode
} }
// 不需要数据库连接的页面 const PUBLIC_ROUTES = ['/', '/home', '/settings']
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management']
function RouteGuard({ children }: RouteGuardProps) { function RouteGuard({ children }: RouteGuardProps) {
const navigate = useNavigate() const navigate = useNavigate()

View File

@@ -76,7 +76,7 @@
} }
.sidebar-footer { .sidebar-footer {
padding: 0 8px; padding: 0 12px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
padding-top: 12px; padding-top: 12px;
margin-top: 8px; margin-top: 8px;

View File

@@ -1,11 +1,19 @@
import { useState } from 'react' import { useState, useEffect } 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, Bot, Aperture, UserCircle } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import './Sidebar.scss' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
const location = useLocation() const location = useLocation()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
configService.getAuthEnabled().then(setAuthEnabled)
}, [])
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`) return location.pathname === path || location.pathname.startsWith(`${path}/`)
@@ -94,18 +102,21 @@ function Sidebar() {
<span className="nav-label"></span> <span className="nav-label"></span>
</NavLink> </NavLink>
{/* 数据管理 */}
<NavLink
to="/data-management"
className={`nav-item ${isActive('/data-management') ? 'active' : ''}`}
title={collapsed ? '数据管理' : undefined}
>
<span className="nav-icon"><Database size={20} /></span>
<span className="nav-label"></span>
</NavLink>
</nav> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
{authEnabled && (
<button
className="nav-item"
onClick={() => setLocked(true)}
title={collapsed ? '锁定' : undefined}
>
<span className="nav-icon"><Lock size={20} /></span>
<span className="nav-label"></span>
</button>
)}
<NavLink <NavLink
to="/settings" to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`} className={`nav-item ${isActive('/settings') ? 'active' : ''}`}

View File

@@ -171,6 +171,29 @@
.actions { .actions {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 12px;
.btn-ignore {
background: transparent;
color: #666666;
border: 1px solid #d0d0d0;
padding: 16px 32px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
border-color: #999999;
color: #333333;
}
&:active {
transform: scale(0.98);
}
}
.btn-update { .btn-update {
background: #000000; background: #000000;

View File

@@ -12,6 +12,7 @@ interface UpdateDialogProps {
updateInfo: UpdateInfo | null updateInfo: UpdateInfo | null
onClose: () => void onClose: () => void
onUpdate: () => void onUpdate: () => void
onIgnore?: () => void
isDownloading: boolean isDownloading: boolean
progress: number | { progress: number | {
percent: number percent: number
@@ -27,6 +28,7 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
updateInfo, updateInfo,
onClose, onClose,
onUpdate, onUpdate,
onIgnore,
isDownloading, isDownloading,
progress progress
}) => { }) => {
@@ -118,6 +120,11 @@ const UpdateDialog: React.FC<UpdateDialogProps> = ({
</div> </div>
) : ( ) : (
<div className="actions"> <div className="actions">
{onIgnore && (
<button className="btn-ignore" onClick={onIgnore}>
</button>
)}
<button className="btn-update" onClick={onUpdate}> <button className="btn-update" onClick={onUpdate}>
</button> </button>

View File

@@ -23,7 +23,7 @@ export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
return return
} }
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload) => { const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (payload.percent !== undefined) { if (payload.percent !== undefined) {
setDownloadProgress(payload.percent) setDownloadProgress(payload.percent)
} }

View File

@@ -47,6 +47,24 @@
} }
} }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
h1 {
margin: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
@keyframes spin { @keyframes spin {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@@ -293,3 +311,184 @@
} }
} }
} }
// 排除好友弹窗
.exclude-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.exclude-modal {
width: 560px;
max-width: calc(100vw - 48px);
background: var(--card-bg);
border-radius: 16px;
border: 1px solid var(--border-color);
padding: 20px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
.exclude-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
color: var(--text-primary);
}
}
.modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.exclude-modal-search {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
margin-bottom: 12px;
color: var(--text-tertiary);
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
}
.clear-search {
background: none;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 2px;
&:hover {
color: var(--text-primary);
}
}
}
.exclude-modal-body {
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.exclude-loading,
.exclude-error,
.exclude-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
padding: 24px 0;
font-size: 13px;
}
.exclude-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.exclude-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 10px;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
background: var(--bg-primary);
&:hover {
background: var(--bg-tertiary);
}
&.active {
border-color: rgba(7, 193, 96, 0.4);
background: rgba(7, 193, 96, 0.08);
}
input {
margin: 0;
}
}
.exclude-avatar {
flex-shrink: 0;
}
.exclude-info {
display: flex;
flex-direction: column;
min-width: 0;
gap: 2px;
}
.exclude-name {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-username {
font-size: 12px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exclude-modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.exclude-count {
font-size: 12px;
color: var(--text-tertiary);
}
.exclude-actions {
display: flex;
gap: 8px;
}
}

View File

@@ -1,21 +1,51 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useLocation } from 'react-router-dom' 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, Medal, UserMinus, Search, X } 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 { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
interface ExcludeCandidate {
username: string
displayName: string
avatarUrl?: string
wechatId?: string
}
const normalizeUsername = (value: string) => value.trim().toLowerCase()
function AnalyticsPage() { function AnalyticsPage() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [loadingStatus, setLoadingStatus] = useState('') const [loadingStatus, setLoadingStatus] = useState('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const [isExcludeDialogOpen, setIsExcludeDialogOpen] = useState(false)
const [excludeCandidates, setExcludeCandidates] = useState<ExcludeCandidate[]>([])
const [excludeQuery, setExcludeQuery] = useState('')
const [excludeLoading, setExcludeLoading] = useState(false)
const [excludeError, setExcludeError] = useState<string | null>(null)
const [excludedUsernames, setExcludedUsernames] = useState<Set<string>>(new Set())
const [draftExcluded, setDraftExcluded] = useState<Set<string>>(new Set())
const themeMode = useThemeStore((state) => state.themeMode) const themeMode = useThemeStore((state) => state.themeMode)
const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded } = useAnalyticsStore() const { statistics, rankings, timeDistribution, isLoaded, setStatistics, setRankings, setTimeDistribution, markLoaded, clearCache } = useAnalyticsStore()
const loadExcludedUsernames = useCallback(async () => {
try {
const result = await window.electronAPI.analytics.getExcludedUsernames()
if (result.success && result.data) {
setExcludedUsernames(new Set(result.data.map(normalizeUsername)))
} else {
setExcludedUsernames(new Set())
}
} catch (e) {
console.warn('加载排除名单失败', e)
setExcludedUsernames(new Set())
}
}, [])
const loadData = useCallback(async (forceRefresh = false) => { const loadData = useCallback(async (forceRefresh = false) => {
if (isLoaded && !forceRefresh) return if (isLoaded && !forceRefresh) return
setIsLoading(true) setIsLoading(true)
@@ -66,14 +96,89 @@ function AnalyticsPage() {
useEffect(() => { useEffect(() => {
const handleChange = () => { const handleChange = () => {
loadExcludedUsernames()
loadData(true) loadData(true)
} }
window.addEventListener('wxid-changed', handleChange as EventListener) window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener) return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [loadData]) }, [loadData, loadExcludedUsernames])
useEffect(() => {
loadExcludedUsernames()
}, [loadExcludedUsernames])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)
const loadExcludeCandidates = useCallback(async () => {
setExcludeLoading(true)
setExcludeError(null)
try {
const result = await window.electronAPI.analytics.getExcludeCandidates()
if (result.success && result.data) {
setExcludeCandidates(result.data)
} else {
setExcludeError(result.error || '加载好友列表失败')
}
} catch (e) {
setExcludeError(String(e))
} finally {
setExcludeLoading(false)
}
}, [])
const openExcludeDialog = async () => {
setExcludeQuery('')
setDraftExcluded(new Set(excludedUsernames))
setIsExcludeDialogOpen(true)
await loadExcludeCandidates()
}
const toggleExcluded = (username: string) => {
const key = normalizeUsername(username)
setDraftExcluded((prev) => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
}
const handleApplyExcluded = async () => {
const payload = Array.from(draftExcluded)
setIsExcludeDialogOpen(false)
try {
const result = await window.electronAPI.analytics.setExcludedUsernames(payload)
if (!result.success) {
alert(result.error || '更新排除名单失败')
return
}
setExcludedUsernames(new Set((result.data || payload).map(normalizeUsername)))
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
alert(`更新排除名单失败:${String(e)}`)
}
}
const visibleExcludeCandidates = excludeCandidates
.filter((candidate) => {
const query = excludeQuery.trim().toLowerCase()
if (!query) return true
const wechatId = candidate.wechatId || ''
const haystack = `${candidate.displayName} ${candidate.username} ${wechatId}`.toLowerCase()
return haystack.includes(query)
})
.sort((a, b) => {
const aSelected = draftExcluded.has(normalizeUsername(a.username))
const bSelected = draftExcluded.has(normalizeUsername(b.username))
if (aSelected !== bSelected) return aSelected ? -1 : 1
return a.displayName.localeCompare(b.displayName, 'zh')
})
const formatDate = (timestamp: number | null) => { const formatDate = (timestamp: number | null) => {
if (!timestamp) return '-' if (!timestamp) return '-'
const date = new Date(timestamp * 1000) const date = new Date(timestamp * 1000)
@@ -248,10 +353,16 @@ function AnalyticsPage() {
<> <>
<div className="page-header"> <div className="page-header">
<h1></h1> <h1></h1>
<div className="header-actions">
<button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}> <button className="btn btn-secondary" onClick={handleRefresh} disabled={isLoading}>
<RefreshCw size={16} className={isLoading ? 'spin' : ''} /> <RefreshCw size={16} className={isLoading ? 'spin' : ''} />
{isLoading ? '刷新中...' : '刷新'} {isLoading ? '刷新中...' : '刷新'}
</button> </button>
<button className="btn btn-secondary" onClick={openExcludeDialog}>
<UserMinus size={16} />
{excludedUsernames.size > 0 ? ` (${excludedUsernames.size})` : ''}
</button>
</div>
</div> </div>
<div className="page-scroll"> <div className="page-scroll">
<section className="page-section"> <section className="page-section">
@@ -317,6 +428,84 @@ function AnalyticsPage() {
</div> </div>
</section> </section>
</div> </div>
{isExcludeDialogOpen && (
<div className="exclude-modal-overlay" onClick={() => setIsExcludeDialogOpen(false)}>
<div className="exclude-modal" onClick={e => e.stopPropagation()}>
<div className="exclude-modal-header">
<h3></h3>
<button className="modal-close" onClick={() => setIsExcludeDialogOpen(false)}>
<X size={18} />
</button>
</div>
<div className="exclude-modal-search">
<Search size={16} />
<input
type="text"
placeholder="搜索好友"
value={excludeQuery}
onChange={e => setExcludeQuery(e.target.value)}
disabled={excludeLoading}
/>
{excludeQuery && (
<button className="clear-search" onClick={() => setExcludeQuery('')}>
<X size={14} />
</button>
)}
</div>
<div className="exclude-modal-body">
{excludeLoading && (
<div className="exclude-loading">
<Loader2 size={20} className="spin" />
<span>...</span>
</div>
)}
{!excludeLoading && excludeError && (
<div className="exclude-error">{excludeError}</div>
)}
{!excludeLoading && !excludeError && (
<div className="exclude-list">
{visibleExcludeCandidates.map((candidate) => {
const isChecked = draftExcluded.has(normalizeUsername(candidate.username))
const wechatId = candidate.wechatId?.trim() || candidate.username
return (
<label key={candidate.username} className={`exclude-item ${isChecked ? 'active' : ''}`}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleExcluded(candidate.username)}
/>
<div className="exclude-avatar">
<Avatar src={candidate.avatarUrl} name={candidate.displayName} size={32} />
</div>
<div className="exclude-info">
<span className="exclude-name">{candidate.displayName}</span>
<span className="exclude-username">{wechatId}</span>
</div>
</label>
)
})}
{visibleExcludeCandidates.length === 0 && (
<div className="exclude-empty">
{excludeQuery.trim() ? '未找到匹配好友' : '暂无可选好友'}
</div>
)}
</div>
)}
</div>
<div className="exclude-modal-footer">
<span className="exclude-count"> {draftExcluded.size} </span>
<div className="exclude-actions">
<button className="btn btn-secondary" onClick={() => setIsExcludeDialogOpen(false)}>
</button>
<button className="btn btn-primary" onClick={handleApplyExcluded} disabled={excludeLoading}>
</button>
</div>
</div>
</div>
</div>
)}
</> </>
) )
} }

View File

@@ -5,6 +5,7 @@
justify-content: center; justify-content: center;
min-height: 100%; min-height: 100%;
text-align: center; text-align: center;
padding: 40px 24px;
} }
.header-icon { .header-icon {
@@ -25,6 +26,63 @@
margin: 0 0 48px; margin: 0 0 48px;
} }
.report-sections {
display: flex;
flex-direction: column;
gap: 32px;
width: min(760px, 100%);
}
.report-section {
width: 100%;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 28px;
text-align: left;
}
.section-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.section-desc {
margin: 8px 0 0;
font-size: 14px;
color: var(--text-tertiary);
}
.section-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.section-hint {
margin: 12px 0 0;
font-size: 12px;
color: var(--text-tertiary);
}
.year-grid { .year-grid {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -34,6 +92,12 @@
margin-bottom: 48px; margin-bottom: 48px;
} }
.report-section .year-grid {
justify-content: flex-start;
max-width: none;
margin-bottom: 24px;
}
.year-card { .year-card {
width: 120px; width: 120px;
height: 100px; height: 100px;
@@ -104,6 +168,13 @@
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
&.secondary {
background: var(--card-bg);
color: var(--text-primary);
border: 1px solid var(--border-color);
box-shadow: none;
}
} }
.spin { .spin {

View File

@@ -1,12 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, Loader2, Sparkles } from 'lucide-react' import { Calendar, Loader2, Sparkles, Users } from 'lucide-react'
import './AnnualReportPage.scss' import './AnnualReportPage.scss'
type YearOption = number | 'all'
function AnnualReportPage() { function AnnualReportPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [availableYears, setAvailableYears] = useState<number[]>([]) const [availableYears, setAvailableYears] = useState<number[]>([])
const [selectedYear, setSelectedYear] = useState<number | null>(null) const [selectedYear, setSelectedYear] = useState<YearOption | null>(null)
const [selectedPairYear, setSelectedPairYear] = useState<YearOption | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isGenerating, setIsGenerating] = useState(false) const [isGenerating, setIsGenerating] = useState(false)
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null)
@@ -22,7 +25,8 @@ function AnnualReportPage() {
const result = await window.electronAPI.annualReport.getAvailableYears() const result = await window.electronAPI.annualReport.getAvailableYears()
if (result.success && result.data && result.data.length > 0) { if (result.success && result.data && result.data.length > 0) {
setAvailableYears(result.data) setAvailableYears(result.data)
setSelectedYear(result.data[0]) setSelectedYear((prev) => prev ?? result.data[0])
setSelectedPairYear((prev) => prev ?? result.data[0])
} else if (!result.success) { } else if (!result.success) {
setLoadError(result.error || '加载年度数据失败') setLoadError(result.error || '加载年度数据失败')
} }
@@ -35,10 +39,11 @@ function AnnualReportPage() {
} }
const handleGenerateReport = async () => { const handleGenerateReport = async () => {
if (!selectedYear) return if (selectedYear === null) return
setIsGenerating(true) setIsGenerating(true)
try { try {
navigate(`/annual-report/view?year=${selectedYear}`) const yearParam = selectedYear === 'all' ? 0 : selectedYear
navigate(`/annual-report/view?year=${yearParam}`)
} catch (e) { } catch (e) {
console.error('生成报告失败:', e) console.error('生成报告失败:', e)
} finally { } finally {
@@ -46,6 +51,12 @@ function AnnualReportPage() {
} }
} }
const handleGenerateDualReport = () => {
if (selectedPairYear === null) return
const yearParam = selectedPairYear === 'all' ? 0 : selectedPairYear
navigate(`/dual-report?year=${yearParam}`)
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="annual-report-page"> <div className="annual-report-page">
@@ -67,21 +78,39 @@ function AnnualReportPage() {
) )
} }
const yearOptions: YearOption[] = availableYears.length > 0
? ['all', ...availableYears]
: []
const getYearLabel = (value: YearOption | null) => {
if (!value) return ''
return value === 'all' ? '全部时间' : `${value}`
}
return ( return (
<div className="annual-report-page"> <div className="annual-report-page">
<Sparkles size={32} className="header-icon" /> <Sparkles size={32} className="header-icon" />
<h1 className="page-title"></h1> <h1 className="page-title"></h1>
<p className="page-desc"></p> <p className="page-desc"></p>
<div className="report-sections">
<section className="report-section">
<div className="section-header">
<div>
<h2 className="section-title"></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="year-grid"> <div className="year-grid">
{availableYears.map(year => ( {yearOptions.map(option => (
<div <div
key={year} key={option}
className={`year-card ${selectedYear === year ? 'selected' : ''}`} className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedYear === option ? 'selected' : ''}`}
onClick={() => setSelectedYear(year)} onClick={() => setSelectedYear(option)}
> >
<span className="year-number">{year}</span> <span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label"></span> <span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div> </div>
))} ))}
</div> </div>
@@ -99,10 +128,48 @@ function AnnualReportPage() {
) : ( ) : (
<> <>
<Sparkles size={20} /> <Sparkles size={20} />
<span> {selectedYear} </span> <span> {getYearLabel(selectedYear)} </span>
</> </>
)} )}
</button> </button>
</section>
<section className="report-section">
<div className="section-header">
<div>
<h2 className="section-title"></h2>
<p className="section-desc"></p>
</div>
<div className="section-badge">
<Users size={16} />
<span></span>
</div>
</div>
<div className="year-grid">
{yearOptions.map(option => (
<div
key={`pair-${option}`}
className={`year-card ${option === 'all' ? 'all-time' : ''} ${selectedPairYear === option ? 'selected' : ''}`}
onClick={() => setSelectedPairYear(option)}
>
<span className="year-number">{option === 'all' ? '全部' : option}</span>
<span className="year-label">{option === 'all' ? '时间' : '年'}</span>
</div>
))}
</div>
<button
className="generate-btn secondary"
onClick={handleGenerateDualReport}
disabled={!selectedPairYear}
>
<Users size={20} />
<span></span>
</button>
<p className="section-hint"></p>
</section>
</div>
</div> </div>
) )
} }

View File

@@ -1279,3 +1279,134 @@
color: var(--ar-text-sub) !important; color: var(--ar-text-sub) !important;
text-align: center; text-align: center;
} }
// 曾经的好朋友 视觉效果
.lost-friend-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
margin: 64px auto 48px;
position: relative;
max-width: 480px;
.avatar-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
z-index: 2;
.avatar-label {
font-size: 13px;
color: var(--ar-text-sub);
font-weight: 500;
opacity: 0.6;
}
&.sender {
animation: fadeInRight 1s ease-out backwards;
}
&.receiver {
animation: fadeInLeft 1s ease-out backwards;
}
}
.fading-line {
position: relative;
flex: 1;
height: 2px;
min-width: 120px;
display: flex;
align-items: center;
justify-content: center;
.line-path {
width: 100%;
height: 100%;
background: linear-gradient(to right,
var(--ar-primary) 0%,
rgba(var(--ar-primary-rgb), 0.4) 50%,
rgba(var(--ar-primary-rgb), 0.05) 100%);
border-radius: 2px;
}
.line-glow {
position: absolute;
inset: -4px 0;
background: linear-gradient(to right,
rgba(var(--ar-primary-rgb), 0.2) 0%,
transparent 100%);
filter: blur(8px);
pointer-events: none;
}
.flow-particle {
position: absolute;
width: 40px;
height: 2px;
background: linear-gradient(to right, transparent, var(--ar-primary), transparent);
border-radius: 2px;
opacity: 0;
animation: flowAcross 4s infinite linear;
}
}
}
.hero-desc.fading {
opacity: 0.7;
font-style: italic;
font-size: 16px;
margin-top: 32px;
line-height: 1.8;
letter-spacing: 0.05em;
animation: fadeIn 1.5s ease-out 0.5s backwards;
}
@keyframes flowAcross {
0% {
left: -20%;
opacity: 0;
}
10% {
opacity: 0.8;
}
50% {
opacity: 0.4;
}
90% {
opacity: 0.1;
}
100% {
left: 120%;
opacity: 0;
}
}
@keyframes fadeInRight {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -71,6 +71,20 @@ interface AnnualReportData {
socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null
responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null
topPhrases?: { phrase: string; count: number }[] topPhrases?: { phrase: string; count: number }[]
snsStats?: {
totalPosts: number
typeCounts?: Record<string, number>
topLikers: { username: string; displayName: string; avatarUrl?: string; count: number }[]
topLiked: { username: string; displayName: string; avatarUrl?: string; count: number }[]
}
lostFriend: {
username: string
displayName: string
avatarUrl?: string
earlyCount: number
lateCount: number
periodDesc: string
} | null
} }
interface SectionInfo { interface SectionInfo {
@@ -274,6 +288,8 @@ function AnnualReportWindow() {
responseSpeed: useRef<HTMLElement>(null), responseSpeed: useRef<HTMLElement>(null),
topPhrases: useRef<HTMLElement>(null), topPhrases: useRef<HTMLElement>(null),
ranking: useRef<HTMLElement>(null), ranking: useRef<HTMLElement>(null),
sns: useRef<HTMLElement>(null),
lostFriend: useRef<HTMLElement>(null),
ending: useRef<HTMLElement>(null), ending: useRef<HTMLElement>(null),
} }
@@ -282,7 +298,8 @@ function AnnualReportWindow() {
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '') const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year') const yearParam = params.get('year')
const year = yearParam ? parseInt(yearParam) : new Date().getFullYear() const parsedYear = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear()
const year = Number.isNaN(parsedYear) ? new Date().getFullYear() : parsedYear
generateReport(year) generateReport(year)
}, []) }, [])
@@ -337,6 +354,11 @@ function AnnualReportWindow() {
return `${Math.round(seconds / 3600)}小时` return `${Math.round(seconds / 3600)}小时`
} }
const formatYearLabel = (value: number, withSuffix: boolean = true) => {
if (value === 0) return '历史以来'
return withSuffix ? `${value}` : `${value}`
}
// 获取可用的板块列表 // 获取可用的板块列表
const getAvailableSections = (): SectionInfo[] => { const getAvailableSections = (): SectionInfo[] => {
if (!reportData) return [] if (!reportData) return []
@@ -367,10 +389,16 @@ function AnnualReportWindow() {
if (reportData.responseSpeed) { if (reportData.responseSpeed) {
sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed }) sections.push({ id: 'responseSpeed', name: '回应速度', ref: sectionRefs.responseSpeed })
} }
if (reportData.lostFriend) {
sections.push({ id: 'lostFriend', name: '曾经的好朋友', ref: sectionRefs.lostFriend })
}
if (reportData.topPhrases && reportData.topPhrases.length > 0) { if (reportData.topPhrases && reportData.topPhrases.length > 0) {
sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases }) sections.push({ id: 'topPhrases', name: '年度常用语', ref: sectionRefs.topPhrases })
} }
sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking }) sections.push({ id: 'ranking', name: '好友排行', ref: sectionRefs.ranking })
if (reportData.snsStats && reportData.snsStats.totalPosts > 0) {
sections.push({ id: 'sns', name: '朋友圈', ref: sectionRefs.sns })
}
sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending }) sections.push({ id: 'ending', name: '尾声', ref: sectionRefs.ending })
return sections return sections
} }
@@ -595,7 +623,8 @@ 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}年度报告${filterIds ? '_自定义' : ''}.png` const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
link.download = `${yearFilePrefix}年度报告${filterIds ? '_自定义' : ''}.png`
link.href = dataUrl link.href = dataUrl
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
@@ -658,11 +687,12 @@ function AnnualReportWindow() {
} }
setExportProgress('正在写入文件...') setExportProgress('正在写入文件...')
const yearFilePrefix = reportData ? formatYearLabel(reportData.year, false) : ''
const exportResult = await window.electronAPI.annualReport.exportImages({ const exportResult = await window.electronAPI.annualReport.exportImages({
baseDir: dirResult.filePaths[0], baseDir: dirResult.filePaths[0],
folderName: `${reportData?.year}年度报告_分模块`, folderName: `${yearFilePrefix}年度报告_分模块`,
images: exportedImages.map((img) => ({ images: exportedImages.map((img) => ({
name: `${reportData?.year}年度报告_${img.name}.png`, name: `${yearFilePrefix}年度报告_${img.name}.png`,
dataUrl: img.data dataUrl: img.data
})) }))
}) })
@@ -733,10 +763,14 @@ function AnnualReportWindow() {
) )
} }
const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases } = reportData const { year, totalMessages, totalFriends, coreFriends, monthlyTopFriends, peakDay, longestStreak, activityHeatmap, midnightKing, selfAvatarUrl, mutualFriend, socialInitiative, responseSpeed, topPhrases, lostFriend } = reportData
const topFriend = coreFriends[0] const topFriend = coreFriends[0]
const mostActive = getMostActiveTime(activityHeatmap.data) const mostActive = getMostActiveTime(activityHeatmap.data)
const socialStoryName = topFriend?.displayName || '好友' const socialStoryName = topFriend?.displayName || '好友'
const yearTitle = formatYearLabel(year, true)
const yearTitleShort = formatYearLabel(year, false)
const monthlyTitle = year === 0 ? '历史以来月度好友' : `${year}年月度好友`
const phrasesTitle = year === 0 ? '你在历史以来的常用语' : `你在${year}年的年度常用语`
return ( return (
<div className="annual-report-window"> <div className="annual-report-window">
@@ -827,7 +861,7 @@ function AnnualReportWindow() {
{/* 封面 */} {/* 封面 */}
<section className="section" ref={sectionRefs.cover}> <section className="section" ref={sectionRefs.cover}>
<div className="label-text">WEFLOW · ANNUAL REPORT</div> <div className="label-text">WEFLOW · ANNUAL REPORT</div>
<h1 className="hero-title">{year}<br /></h1> <h1 className="hero-title">{yearTitle}<br /></h1>
<hr className="divider" /> <hr className="divider" />
<p className="hero-desc"><br /></p> <p className="hero-desc"><br /></p>
</section> </section>
@@ -869,7 +903,7 @@ function AnnualReportWindow() {
{/* 月度好友 */} {/* 月度好友 */}
<section className="section" ref={sectionRefs.monthlyFriends}> <section className="section" ref={sectionRefs.monthlyFriends}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title">{year}</h2> <h2 className="hero-title">{monthlyTitle}</h2>
<p className="hero-desc">12</p> <p className="hero-desc">12</p>
<div className="monthly-orbit"> <div className="monthly-orbit">
{monthlyTopFriends.map((m, i) => ( {monthlyTopFriends.map((m, i) => (
@@ -883,7 +917,7 @@ function AnnualReportWindow() {
<Avatar url={selfAvatarUrl} name="我" size="lg" /> <Avatar url={selfAvatarUrl} name="我" size="lg" />
</div> </div>
</div> </div>
<p className="hero-desc"><br /></p> <p className="hero-desc"><br /></p>
</section> </section>
{/* 双向奔赴 */} {/* 双向奔赴 */}
@@ -983,15 +1017,15 @@ function AnnualReportWindow() {
{midnightKing && ( {midnightKing && (
<section className="section" ref={sectionRefs.midnightKing}> <section className="section" ref={sectionRefs.midnightKing}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title"></h2> <h2 className="hero-title"></h2>
<p className="hero-desc"></p> <p className="hero-desc"></p>
<div className="big-stat"> <div className="big-stat">
<span className="stat-num">{midnightKing.count}</span> <span className="stat-num">{midnightKing.count}</span>
<span className="stat-unit"></span> <span className="stat-unit"></span>
</div> </div>
<p className="hero-desc"> <p className="hero-desc">
<span className="hl">{midnightKing.displayName}</span> <span className="hl">{midnightKing.displayName}</span>
<br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span> <br />Ta的对话占深夜期间聊天的 <span className="gold">{midnightKing.percentage}%</span>
</p> </p>
</section> </section>
)} )}
@@ -1012,11 +1046,46 @@ function AnnualReportWindow() {
</section> </section>
)} )}
{/* 曾经的好朋友 */}
{lostFriend && (
<section className="section" ref={sectionRefs.lostFriend}>
<div className="label-text"></div>
<h2 className="hero-title">{lostFriend.displayName}</h2>
<div className="big-stat">
<span className="stat-num">{formatNumber(lostFriend.earlyCount)}</span>
<span className="stat-unit"></span>
</div>
<p className="hero-desc">
<span className="hl">{lostFriend.periodDesc}</span>
<br />
</p>
<div className="lost-friend-visual">
<div className="avatar-group sender">
<Avatar url={lostFriend.avatarUrl} name={lostFriend.displayName} size="lg" />
<span className="avatar-label">TA</span>
</div>
<div className="fading-line">
<div className="line-path" />
<div className="line-glow" />
<div className="flow-particle" />
</div>
<div className="avatar-group receiver">
<Avatar url={selfAvatarUrl} name="我" size="lg" />
<span className="avatar-label"></span>
</div>
</div>
<p className="hero-desc fading">
<br />
</p>
</section>
)}
{/* 年度常用语 - 词云 */} {/* 年度常用语 - 词云 */}
{topPhrases && topPhrases.length > 0 && ( {topPhrases && topPhrases.length > 0 && (
<section className="section" ref={sectionRefs.topPhrases}> <section className="section" ref={sectionRefs.topPhrases}>
<div className="label-text"></div> <div className="label-text"></div>
<h2 className="hero-title">{year}</h2> <h2 className="hero-title">{phrasesTitle}</h2>
<p className="hero-desc"> <p className="hero-desc">
<br /> <br />
@@ -1029,6 +1098,57 @@ function AnnualReportWindow() {
</section> </section>
)} )}
{/* 朋友圈 */}
{reportData.snsStats && reportData.snsStats.totalPosts > 0 && (
<section className="section" ref={sectionRefs.sns}>
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc">
</p>
<div className="big-stat">
<span className="stat-num">{reportData.snsStats.totalPosts}</span>
<span className="stat-unit"></span>
</div>
<div className="sns-stats-container" style={{ display: 'flex', gap: '60px', marginTop: '40px', justifyContent: 'center' }}>
{reportData.snsStats.topLikers.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLikers.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
{reportData.snsStats.topLiked.length > 0 && (
<div className="sns-sub-stat" style={{ textAlign: 'left' }}>
<h3 className="sub-title" style={{ fontSize: '18px', marginBottom: '16px', opacity: 0.8, borderBottom: '1px solid currentColor', paddingBottom: '8px' }}>Ta</h3>
<div className="mini-ranking">
{reportData.snsStats.topLiked.slice(0, 3).map((u, i) => (
<div key={i} className="mini-rank-item" style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '14px' }}>
<Avatar url={u.avatarUrl} name={u.displayName} size="sm" />
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span className="name" style={{ fontSize: '15px', fontWeight: 500, maxWidth: '120px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{u.displayName}</span>
</div>
<span className="count hl" style={{ fontSize: '14px', marginLeft: 'auto' }}>{u.count}</span>
</div>
))}
</div>
</div>
)}
</div>
</section>
)}
{/* 好友排行 */} {/* 好友排行 */}
<section className="section" ref={sectionRefs.ranking}> <section className="section" ref={sectionRefs.ranking}>
<div className="label-text"></div> <div className="label-text"></div>
@@ -1085,7 +1205,7 @@ function AnnualReportWindow() {
<br /> <br />
<br /> <br />
</p> </p>
<div className="ending-year">{year}</div> <div className="ending-year">{yearTitleShort}</div>
<div className="ending-brand">WEFLOW</div> <div className="ending-brand">WEFLOW</div>
</section> </section>
</div> </div>

View File

@@ -2146,8 +2146,7 @@
} }
.video-placeholder, .video-placeholder,
.video-loading, .video-loading {
.video-unavailable {
min-width: 120px; min-width: 120px;
min-height: 80px; min-height: 80px;
display: flex; display: flex;
@@ -2167,6 +2166,46 @@
} }
} }
.video-unavailable {
min-width: 160px;
min-height: 120px;
border-radius: 12px;
background: var(--bg-tertiary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--text-tertiary);
font-size: 12px;
border: none;
cursor: pointer;
text-align: center;
-webkit-app-region: no-drag;
transition: transform 0.15s ease, box-shadow 0.15s ease;
svg {
width: 24px;
height: 24px;
opacity: 0.6;
}
&.clicked {
transform: scale(0.98);
box-shadow: 0 0 0 2px var(--primary-light);
}
&:disabled {
cursor: default;
opacity: 0.7;
}
}
.video-action {
font-size: 11px;
color: var(--text-quaternary);
}
.video-loading { .video-loading {
.spin { .spin {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;

View File

@@ -4,7 +4,6 @@ import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models' import type { ChatSession, Message } from '../types/models'
import { getEmojiPath } from 'wechat-emojis' import { getEmojiPath } from 'wechat-emojis'
import { ImagePreview } from '../components/ImagePreview'
import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
@@ -192,6 +191,7 @@ function ChatPage(_props: ChatPageProps) {
const isLoadingMessagesRef = useRef(false) const isLoadingMessagesRef = useRef(false)
const isLoadingMoreRef = useRef(false) const isLoadingMoreRef = useRef(false)
const isConnectedRef = useRef(false) const isConnectedRef = useRef(false)
const isRefreshingRef = useRef(false)
const searchKeywordRef = useRef('') const searchKeywordRef = useRef('')
const preloadImageKeysRef = useRef<Set<string>>(new Set()) const preloadImageKeysRef = useRef<Set<string>>(new Set())
const lastPreloadSessionRef = useRef<string | null>(null) const lastPreloadSessionRef = useRef<string | null>(null)
@@ -286,6 +286,11 @@ function ChatPage(_props: ChatPageProps) {
setSessions setSessions
]) ])
// 同步 currentSessionId 到 ref
useEffect(() => {
currentSessionRef.current = currentSessionId
}, [currentSessionId])
// 加载会话列表(优化:先返回基础数据,异步加载联系人信息) // 加载会话列表(优化:先返回基础数据,异步加载联系人信息)
const loadSessions = async (options?: { silent?: boolean }) => { const loadSessions = async (options?: { silent?: boolean }) => {
if (options?.silent) { if (options?.silent) {
@@ -301,7 +306,10 @@ function ChatPage(_props: ChatPageProps) {
const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray const nextSessions = options?.silent ? mergeSessions(sessionsArray) : sessionsArray
// 确保 nextSessions 也是数组 // 确保 nextSessions 也是数组
if (Array.isArray(nextSessions)) { if (Array.isArray(nextSessions)) {
setSessions(nextSessions) setSessions(nextSessions)
sessionsRef.current = nextSessions
// 立即启动联系人信息加载,不再延迟 500ms // 立即启动联系人信息加载,不再延迟 500ms
void enrichSessionsContactInfo(nextSessions) void enrichSessionsContactInfo(nextSessions)
} else { } else {
@@ -330,14 +338,14 @@ function ChatPage(_props: ChatPageProps) {
// 防止重复加载 // 防止重复加载
if (isEnrichingRef.current) { if (isEnrichingRef.current) {
console.log('[性能监控] 联系人信息正在加载中,跳过重复请求')
return return
} }
isEnrichingRef.current = true isEnrichingRef.current = true
enrichCancelledRef.current = false enrichCancelledRef.current = false
console.log(`[性能监控] 开始加载联系人信息,会话数: ${sessions.length}`)
const totalStart = performance.now() const totalStart = performance.now()
// 移除初始 500ms 延迟,让后台加载与 UI 渲染并行 // 移除初始 500ms 延迟,让后台加载与 UI 渲染并行
@@ -352,12 +360,12 @@ function ChatPage(_props: ChatPageProps) {
// 找出需要加载联系人信息的会话(没有头像或者没有显示名称的) // 找出需要加载联系人信息的会话(没有头像或者没有显示名称的)
const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username) const needEnrich = sessions.filter(s => !s.avatarUrl || !s.displayName || s.displayName === s.username)
if (needEnrich.length === 0) { if (needEnrich.length === 0) {
console.log('[性能监控] 所有联系人信息已缓存,跳过加载')
isEnrichingRef.current = false isEnrichingRef.current = false
return return
} }
console.log(`[性能监控] 需要加载的联系人信息: ${needEnrich.length}`)
// 进一步减少批次大小每批3个避免DLL调用阻塞 // 进一步减少批次大小每批3个避免DLL调用阻塞
const batchSize = 3 const batchSize = 3
@@ -366,7 +374,7 @@ function ChatPage(_props: ChatPageProps) {
for (let i = 0; i < needEnrich.length; i += batchSize) { for (let i = 0; i < needEnrich.length; i += batchSize) {
// 如果正在滚动,暂停加载 // 如果正在滚动,暂停加载
if (isScrollingRef.current) { if (isScrollingRef.current) {
console.log('[性能监控] 检测到滚动,暂停加载联系人信息')
// 等待滚动结束 // 等待滚动结束
while (isScrollingRef.current && !enrichCancelledRef.current) { while (isScrollingRef.current && !enrichCancelledRef.current) {
await new Promise(resolve => setTimeout(resolve, 200)) await new Promise(resolve => setTimeout(resolve, 200))
@@ -410,9 +418,9 @@ function ChatPage(_props: ChatPageProps) {
const totalTime = performance.now() - totalStart const totalTime = performance.now() - totalStart
if (!enrichCancelledRef.current) { if (!enrichCancelledRef.current) {
console.log(`[性能监控] 联系人信息加载完成,总耗时: ${totalTime.toFixed(2)}ms, 已加载: ${loadedCount}/${needEnrich.length}`)
} else { } else {
console.log(`[性能监控] 联系人信息加载被取消,已加载: ${loadedCount}/${needEnrich.length}`)
} }
} catch (e) { } catch (e) {
console.error('加载联系人信息失败:', e) console.error('加载联系人信息失败:', e)
@@ -491,7 +499,11 @@ function ChatPage(_props: ChatPageProps) {
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
const dllStart = performance.now() const dllStart = performance.now()
const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const result = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) as {
success: boolean
contacts?: Record<string, { displayName?: string; avatarUrl?: string }>
error?: string
}
const dllTime = performance.now() - dllStart const dllTime = performance.now() - dllStart
// DLL 调用后再次让出控制权 // DLL 调用后再次让出控制权
@@ -504,12 +516,13 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.contacts) { if (result.success && result.contacts) {
// 将更新加入队列,用于侧边栏更新 // 将更新加入队列,用于侧边栏更新
for (const [username, contact] of Object.entries(result.contacts)) { const contacts = result.contacts || {}
for (const [username, contact] of Object.entries(contacts)) {
contactUpdateQueueRef.current.set(username, contact) contactUpdateQueueRef.current.set(username, contact)
// 如果是自己的信息且当前个人头像为空,同步更新 // 如果是自己的信息且当前个人头像为空,同步更新
if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) { if (myWxid && username === myWxid && contact.avatarUrl && !myAvatarUrl) {
console.log('[ChatPage] 从联系人同步获取到个人头像')
setMyAvatarUrl(contact.avatarUrl) setMyAvatarUrl(contact.avatarUrl)
} }
@@ -537,25 +550,82 @@ function ChatPage(_props: ChatPageProps) {
// 刷新当前会话消息(增量更新新消息) // 刷新当前会话消息(增量更新新消息)
const [isRefreshingMessages, setIsRefreshingMessages] = useState(false) const [isRefreshingMessages, setIsRefreshingMessages] = useState(false)
/**
* 极速增量刷新:基于最后一条消息时间戳,获取后续新消息
* (由用户建议:记住上一条消息时间,自动取之后的并渲染,然后后台兜底全量同步)
*/
const handleIncrementalRefresh = async () => {
if (!currentSessionId || isRefreshingRef.current) return
isRefreshingRef.current = true
setIsRefreshingMessages(true)
// 找出当前已渲染消息中的最大时间戳(使用 getState 获取最新状态,避免闭包过时导致重复)
const currentMessages = useChatStore.getState().messages
const lastMsg = currentMessages[currentMessages.length - 1]
const minTime = lastMsg?.createTime || 0
// 1. 优先执行增量查询并渲染(第一步)
try {
const result = await (window.electronAPI.chat as any).getNewMessages(currentSessionId, minTime) as {
success: boolean;
messages?: Message[];
error?: string
}
if (result.success && result.messages && result.messages.length > 0) {
// 过滤去重:必须对比实时的状态,防止在 handleRefreshMessages 运行期间导致的冲突
const latestMessages = useChatStore.getState().messages
const existingKeys = new Set(latestMessages.map(getMessageKey))
const newOnes = result.messages.filter(m => !existingKeys.has(getMessageKey(m)))
if (newOnes.length > 0) {
appendMessages(newOnes, false)
flashNewMessages(newOnes.map(getMessageKey))
// 滚动到底部
requestAnimationFrame(() => {
if (messageListRef.current) {
messageListRef.current.scrollTop = messageListRef.current.scrollHeight
}
})
}
}
} catch (e) {
console.warn('[IncrementalRefresh] 失败,将依赖全量同步兜底:', e)
} finally {
isRefreshingRef.current = false
setIsRefreshingMessages(false)
}
}
const handleRefreshMessages = async () => { const handleRefreshMessages = async () => {
if (!currentSessionId || isRefreshingMessages) return if (!currentSessionId || isRefreshingRef.current) return
setJumpStartTime(0) setJumpStartTime(0)
setJumpEndTime(0) setJumpEndTime(0)
setHasMoreLater(false) setHasMoreLater(false)
setIsRefreshingMessages(true) setIsRefreshingMessages(true)
isRefreshingRef.current = true
try { try {
// 获取最新消息并增量添加 // 获取最新消息并增量添加
const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) const result = await window.electronAPI.chat.getLatestMessages(currentSessionId, 50) as {
success: boolean;
messages?: Message[];
error?: string
}
if (!result.success || !result.messages) { if (!result.success || !result.messages) {
return return
} }
const existing = new Set(messages.map(getMessageKey)) // 使用实时状态进行去重对比
const lastMsg = messages[messages.length - 1] const latestMessages = useChatStore.getState().messages
const existing = new Set(latestMessages.map(getMessageKey))
const lastMsg = latestMessages[latestMessages.length - 1]
const lastTime = lastMsg?.createTime ?? 0 const lastTime = lastMsg?.createTime ?? 0
const newMessages = result.messages.filter((msg) => { const newMessages = result.messages.filter((msg) => {
const key = getMessageKey(msg) const key = getMessageKey(msg)
if (existing.has(key)) return false if (existing.has(key)) return false
if (lastTime > 0 && msg.createTime < lastTime) return false // 这里的 lastTime 仅作参考过滤,主要的去重靠 key
if (lastTime > 0 && msg.createTime < lastTime - 3600) return false // 仅过滤 1 小时之前的冗余请求
return true return true
}) })
if (newMessages.length > 0) { if (newMessages.length > 0) {
@@ -571,10 +641,13 @@ function ChatPage(_props: ChatPageProps) {
} catch (e) { } catch (e) {
console.error('刷新消息失败:', e) console.error('刷新消息失败:', e)
} finally { } finally {
isRefreshingRef.current = false
setIsRefreshingMessages(false) setIsRefreshingMessages(false)
} }
} }
// 加载消息 // 加载消息
const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => { const loadMessages = async (sessionId: string, offset = 0, startTime = 0, endTime = 0) => {
const listEl = messageListRef.current const listEl = messageListRef.current
@@ -593,7 +666,12 @@ function ChatPage(_props: ChatPageProps) {
const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null
try { try {
const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) const result = await window.electronAPI.chat.getMessages(sessionId, offset, messageLimit, startTime, endTime) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
@@ -607,7 +685,7 @@ function ChatPage(_props: ChatPageProps) {
.map(m => m.senderUsername as string) .map(m => m.senderUsername as string)
)] )]
if (unknownSenders.length > 0) { if (unknownSenders.length > 0) {
console.log(`[性能监控] 预取消息发送者信息: ${unknownSenders.length}`)
// 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求 // 在批量请求前,先将这些发送者标记为加载中,防止 MessageBubble 触发重复请求
const batchPromise = loadContactInfoBatch(unknownSenders) const batchPromise = loadContactInfoBatch(unknownSenders)
unknownSenders.forEach(username => { unknownSenders.forEach(username => {
@@ -690,7 +768,12 @@ function ChatPage(_props: ChatPageProps) {
try { try {
const lastMsg = messages[messages.length - 1] const lastMsg = messages[messages.length - 1]
// 从最后一条消息的时间开始往后找 // 从最后一条消息的时间开始往后找
const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) const result = await window.electronAPI.chat.getMessages(currentSessionId, 0, 50, lastMsg.createTime, 0, true) as {
success: boolean;
messages?: Message[];
hasMore?: boolean;
error?: string
}
if (result.success && result.messages) { if (result.success && result.messages) {
// 过滤掉已经在列表中的重复消息 // 过滤掉已经在列表中的重复消息
@@ -1501,6 +1584,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const imageClickTimerRef = useRef<number | null>(null) const imageClickTimerRef = useRef<number | null>(null)
const imageContainerRef = useRef<HTMLDivElement>(null) const imageContainerRef = useRef<HTMLDivElement>(null)
const imageAutoDecryptTriggered = useRef(false) const imageAutoDecryptTriggered = useRef(false)
const imageAutoHdTriggered = useRef<string | null>(null)
const [imageInView, setImageInView] = useState(false)
const imageForceHdAttempted = useRef<string | null>(null)
const imageForceHdPending = useRef(false)
const [voiceError, setVoiceError] = useState(false) const [voiceError, setVoiceError] = useState(false)
const [voiceLoading, setVoiceLoading] = useState(false) const [voiceLoading, setVoiceLoading] = useState(false)
const [isVoicePlaying, setIsVoicePlaying] = useState(false) const [isVoicePlaying, setIsVoicePlaying] = useState(false)
@@ -1508,7 +1595,6 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false) const [voiceTranscriptLoading, setVoiceTranscriptLoading] = useState(false)
const [voiceTranscriptError, setVoiceTranscriptError] = useState(false) const [voiceTranscriptError, setVoiceTranscriptError] = useState(false)
const voiceTranscriptRequestedRef = useRef(false) const voiceTranscriptRequestedRef = useRef(false)
const [showImagePreview, setShowImagePreview] = useState(false)
const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true) const [autoTranscribeVoice, setAutoTranscribeVoice] = useState(true)
const [voiceCurrentTime, setVoiceCurrentTime] = useState(0) const [voiceCurrentTime, setVoiceCurrentTime] = useState(0)
const [voiceDuration, setVoiceDuration] = useState(0) const [voiceDuration, setVoiceDuration] = useState(0)
@@ -1526,23 +1612,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isVideo) return if (!isVideo) return
console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2))
console.log('[Video Debug] Message keys:', Object.keys(message))
console.log('[Video Debug] Message:', {
localId: message.localId,
localType: message.localType,
hasVideoMd5: !!message.videoMd5,
hasContent: !!message.content,
hasParsedContent: !!message.parsedContent,
hasRawContent: !!(message as any).rawContent,
contentPreview: message.content?.substring(0, 200),
parsedContentPreview: message.parsedContent?.substring(0, 200),
rawContentPreview: (message as any).rawContent?.substring(0, 200)
})
// 优先使用数据库中的 videoMd5 // 优先使用数据库中的 videoMd5
if (message.videoMd5) { if (message.videoMd5) {
console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5)
setVideoMd5(message.videoMd5) setVideoMd5(message.videoMd5)
return return
} }
@@ -1550,16 +1626,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
// 尝试从多个可能的字段获取原始内容 // 尝试从多个可能的字段获取原始内容
const contentToUse = message.content || (message as any).rawContent || message.parsedContent const contentToUse = message.content || (message as any).rawContent || message.parsedContent
if (contentToUse) { if (contentToUse) {
console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length)
window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => { window.electronAPI.video.parseVideoMd5(contentToUse).then((result: { success: boolean; md5?: string; error?: string }) => {
console.log('[Video Debug] Parse result:', result)
if (result && result.success && result.md5) { if (result && result.success && result.md5) {
console.log('[Video Debug] Parsed MD5:', result.md5)
setVideoMd5(result.md5) setVideoMd5(result.md5)
} else { } else {
console.error('[Video Debug] Failed to parse MD5:', result) console.error('[Video Debug] Failed to parse MD5:', result)
} }
}).catch((err) => { }).catch((err: unknown) => {
console.error('[Video Debug] Parse error:', err) console.error('[Video Debug] Parse error:', err)
}) })
} }
@@ -1667,7 +1743,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
const pending = senderAvatarLoading.get(sender) const pending = senderAvatarLoading.get(sender)
if (pending) { if (pending) {
pending.then((result) => { pending.then((result: { avatarUrl?: string; displayName?: string } | null) => {
if (result) { if (result) {
setSenderAvatarUrl(result.avatarUrl) setSenderAvatarUrl(result.avatarUrl)
setSenderName(result.displayName) setSenderName(result.displayName)
@@ -1697,10 +1773,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
} }
}, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError])
const requestImageDecrypt = useCallback(async (forceUpdate = false) => { const requestImageDecrypt = useCallback(async (forceUpdate = false, silent = false) => {
if (!isImage || imageLoading) return if (!isImage) return
if (imageLoading) return
if (!silent) {
setImageLoading(true) setImageLoading(true)
setImageError(false) setImageError(false)
}
try { try {
if (message.imageMd5 || message.imageDatName) { if (message.imageMd5 || message.imageDatName) {
const result = await window.electronAPI.image.decrypt({ const result = await window.electronAPI.image.decrypt({
@@ -1726,14 +1805,25 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
setImageHasUpdate(false) setImageHasUpdate(false)
return return
} }
setImageError(true) if (!silent) setImageError(true)
} catch { } catch {
setImageError(true) if (!silent) setImageError(true)
} finally { } finally {
setImageLoading(false) if (!silent) setImageLoading(false)
} }
}, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64]) }, [isImage, imageLoading, message.imageMd5, message.imageDatName, message.localId, session.username, imageCacheKey, detectImageMimeFromBase64])
const triggerForceHd = useCallback(() => {
if (!message.imageMd5 && !message.imageDatName) return
if (imageForceHdAttempted.current === imageCacheKey) return
if (imageForceHdPending.current) return
imageForceHdAttempted.current = imageCacheKey
imageForceHdPending.current = true
requestImageDecrypt(true, true).finally(() => {
imageForceHdPending.current = false
})
}, [imageCacheKey, message.imageDatName, message.imageMd5, requestImageDecrypt])
const handleImageClick = useCallback(() => { const handleImageClick = useCallback(() => {
if (imageClickTimerRef.current) { if (imageClickTimerRef.current) {
window.clearTimeout(imageClickTimerRef.current) window.clearTimeout(imageClickTimerRef.current)
@@ -1769,7 +1859,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName
}).then((result) => { }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => {
if (cancelled) return if (cancelled) return
if (result.success && result.localPath) { if (result.success && result.localPath) {
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
@@ -1787,7 +1877,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload) => { const unsubscribe = window.electronAPI.image.onUpdateAvailable((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1804,7 +1894,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isImage) return if (!isImage) return
const unsubscribe = window.electronAPI.image.onCacheResolved((payload) => { const unsubscribe = window.electronAPI.image.onCacheResolved((payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => {
const matchesCacheKey = const matchesCacheKey =
payload.cacheKey === message.imageMd5 || payload.cacheKey === message.imageMd5 ||
payload.cacheKey === message.imageDatName || payload.cacheKey === message.imageDatName ||
@@ -1846,6 +1936,42 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect() return () => observer.disconnect()
}, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt]) }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, requestImageDecrypt])
// 进入视野时自动尝试切换高清图
useEffect(() => {
if (!isImage) return
const container = imageContainerRef.current
if (!container) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setImageInView(entry.isIntersecting)
},
{ rootMargin: '120px', threshold: 0 }
)
observer.observe(container)
return () => observer.disconnect()
}, [isImage])
useEffect(() => {
if (!isImage || !imageHasUpdate || !imageInView) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, imageHasUpdate, imageInView, imageCacheKey, triggerForceHd])
useEffect(() => {
if (!isImage || !imageHasUpdate) return
if (imageAutoHdTriggered.current === imageCacheKey) return
imageAutoHdTriggered.current = imageCacheKey
triggerForceHd()
}, [isImage, imageHasUpdate, imageCacheKey, triggerForceHd])
// 更激进:进入视野/打开预览时,无论 hasUpdate 与否都尝试强制高清
useEffect(() => {
if (!isImage || !imageInView) return
triggerForceHd()
}, [isImage, imageInView, triggerForceHd])
useEffect(() => { useEffect(() => {
if (!isVoice) return if (!isVoice) return
@@ -1933,7 +2059,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
useEffect(() => { useEffect(() => {
if (!isVoice || voiceDataUrl) return if (!isVoice || voiceDataUrl) return
window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId)) window.electronAPI.chat.resolveVoiceCache(session.username, String(message.localId))
.then(result => { .then((result: { success: boolean; hasCache: boolean; data?: string; error?: string }) => {
if (result.success && result.hasCache && result.data) { if (result.success && result.hasCache && result.data) {
const url = `data:audio/wav;base64,${result.data}` const url = `data:audio/wav;base64,${result.data}`
voiceDataUrlCache.set(voiceCacheKey, url) voiceDataUrlCache.set(voiceCacheKey, url)
@@ -1983,11 +2109,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
String(message.localId), String(message.localId),
message.createTime message.createTime
) )
console.log('[ChatPage] 调用转写:', {
sessionId: session.username,
msgId: message.localId,
createTime: message.createTime
})
if (result.success) { if (result.success) {
const transcriptText = (result.transcript || '').trim() const transcriptText = (result.transcript || '').trim()
voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText)
@@ -2033,6 +2155,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
}, [isVoice, message.localId, requestVoiceTranscript]) }, [isVoice, message.localId, requestVoiceTranscript])
// 视频懒加载 // 视频懒加载
const videoAutoLoadTriggered = useRef(false)
const [videoClicked, setVideoClicked] = useState(false)
useEffect(() => { useEffect(() => {
if (!isVideo || !videoContainerRef.current) return if (!isVideo || !videoContainerRef.current) return
@@ -2056,19 +2181,18 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
return () => observer.disconnect() return () => observer.disconnect()
}, [isVideo]) }, [isVideo])
// 加载视频信息 // 视频加载中状态引用,避免依赖问题
useEffect(() => { const videoLoadingRef = useRef(false)
if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return
if (!videoMd5) {
console.log('[Video Debug] No videoMd5 available yet')
return
}
console.log('[Video Debug] Loading video info for MD5:', videoMd5) // 加载视频信息(添加重试机制)
const requestVideoInfo = useCallback(async () => {
if (!videoMd5 || videoLoadingRef.current) return
videoLoadingRef.current = true
setVideoLoading(true) setVideoLoading(true)
window.electronAPI.video.getVideoInfo(videoMd5).then((result) => { try {
console.log('[Video Debug] getVideoInfo result:', result) const result = await window.electronAPI.video.getVideoInfo(videoMd5)
if (result && result.success) { if (result && result.success && result.exists) {
setVideoInfo({ setVideoInfo({
exists: result.exists, exists: result.exists,
videoUrl: result.videoUrl, videoUrl: result.videoUrl,
@@ -2076,23 +2200,32 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
thumbUrl: result.thumbUrl thumbUrl: result.thumbUrl
}) })
} else { } else {
console.error('[Video Debug] Video info failed:', result)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
} }
}).catch((err) => { } catch (err) {
console.error('[Video Debug] getVideoInfo error:', err)
setVideoInfo({ exists: false }) setVideoInfo({ exists: false })
}).finally(() => { } finally {
videoLoadingRef.current = false
setVideoLoading(false) setVideoLoading(false)
}) }
}, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5]) }, [videoMd5])
// 视频进入视野时自动加载
useEffect(() => {
if (!isVideo || !isVideoVisible) return
if (videoInfo?.exists) return // 已成功加载,不需要重试
if (videoAutoLoadTriggered.current) return
videoAutoLoadTriggered.current = true
void requestVideoInfo()
}, [isVideo, isVideoVisible, videoInfo, requestVideoInfo])
// 根据设置决定是否自动转写 // 根据设置决定是否自动转写
const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false)
useEffect(() => { useEffect(() => {
window.electronAPI.config.get('autoTranscribeVoice').then((value) => { window.electronAPI.config.get('autoTranscribeVoice').then((value: unknown) => {
setAutoTranscribeEnabled(value === true) setAutoTranscribeEnabled(value === true)
}) })
}, []) }, [])
@@ -2196,27 +2329,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className="image-message" className="image-message"
onClick={() => setShowImagePreview(true)} onClick={() => {
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
}}
onLoad={() => setImageError(false)} onLoad={() => setImageError(false)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
{imageHasUpdate && (
<button
className="image-update-button"
type="button"
title="发现更高清图片,点击更新"
onClick={(event) => {
event.stopPropagation()
void requestImageDecrypt(true)
}}
>
<RefreshCw size={14} />
</button>
)}
</div> </div>
{showImagePreview && (
<ImagePreview src={imageLocalPath} onClose={() => setShowImagePreview(false)} />
)}
</> </>
)} )}
</div> </div>
@@ -2255,16 +2377,27 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) )
} }
// 视频不存在 // 视频不存在 - 添加点击重试功能
if (!videoInfo?.exists || !videoInfo.videoUrl) { if (!videoInfo?.exists || !videoInfo.videoUrl) {
return ( return (
<div className="video-unavailable" ref={videoContainerRef}> <button
className={`video-unavailable ${videoClicked ? 'clicked' : ''}`}
ref={videoContainerRef}
onClick={() => {
setVideoClicked(true)
setTimeout(() => setVideoClicked(false), 800)
videoAutoLoadTriggered.current = false
void requestVideoInfo()
}}
type="button"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="23 7 16 12 23 17 23 7"></polygon> <polygon points="23 7 16 12 23 17 23 7"></polygon>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect> <rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
</svg> </svg>
<span></span> <span></span>
</div> <span className="video-action">{videoClicked ? '已点击…' : '点击重试'}</span>
</button>
) )
} }
@@ -2614,7 +2747,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const content = message.rawContent || message.content || message.parsedContent || '' const content = message.rawContent || message.content || message.parsedContent || ''
// 添加调试日志 // 添加调试日志
console.log('[Transfer Debug] Raw content:', content.substring(0, 500))
const parser = new DOMParser() const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/xml') const doc = parser.parseFromString(content, 'text/xml')
@@ -2623,7 +2756,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
const payMemo = doc.querySelector('pay_memo')?.textContent || '' const payMemo = doc.querySelector('pay_memo')?.textContent || ''
const paysubtype = doc.querySelector('paysubtype')?.textContent || '1' const paysubtype = doc.querySelector('paysubtype')?.textContent || '1'
console.log('[Transfer Debug] Parsed:', { feedesc, payMemo, paysubtype, title })
// paysubtype: 1=待收款, 3=已收款 // paysubtype: 1=待收款, 3=已收款
const isReceived = paysubtype === '3' const isReceived = paysubtype === '3'

View File

@@ -41,22 +41,17 @@ function ContactsPage() {
return return
} }
const contactsResult = await window.electronAPI.chat.getContacts() const contactsResult = await window.electronAPI.chat.getContacts()
console.log('📞 getContacts结果:', contactsResult)
if (contactsResult.success && contactsResult.contacts) { if (contactsResult.success && contactsResult.contacts) {
console.log('📊 总联系人数:', contactsResult.contacts.length)
console.log('📊 按类型统计:', {
friends: contactsResult.contacts.filter(c => c.type === 'friend').length,
groups: contactsResult.contacts.filter(c => c.type === 'group').length,
officials: contactsResult.contacts.filter(c => c.type === 'official').length,
other: contactsResult.contacts.filter(c => c.type === 'other').length
})
// 获取头像URL // 获取头像URL
const usernames = contactsResult.contacts.map(c => c.username) const usernames = contactsResult.contacts.map((c: ContactInfo) => c.username)
if (usernames.length > 0) { if (usernames.length > 0) {
const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const avatarResult = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (avatarResult.success && avatarResult.contacts) { if (avatarResult.success && avatarResult.contacts) {
contactsResult.contacts.forEach(contact => { contactsResult.contacts.forEach((contact: ContactInfo) => {
const enriched = avatarResult.contacts?.[contact.username] const enriched = avatarResult.contacts?.[contact.username]
if (enriched?.avatarUrl) { if (enriched?.avatarUrl) {
contact.avatarUrl = enriched.avatarUrl contact.avatarUrl = enriched.avatarUrl

View File

@@ -1,569 +0,0 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
h1 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.header-tabs {
display: flex;
gap: 8px;
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
background: var(--bg-tertiary);
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: white;
}
}
}
}
.page-scroll {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-section {
background: var(--bg-secondary);
border-radius: 16px;
padding: 20px 24px;
h2 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px;
}
.section-desc {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
.section-actions {
display: flex;
gap: 10px;
}
}
}
.btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 9999px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
}
.btn-primary {
background: var(--primary);
color: white;
&:hover:not(:disabled) {
background: var(--primary-hover);
}
}
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover:not(:disabled) {
background: var(--border-color);
}
}
.btn-warning {
background: #f59e0b;
color: white;
&:hover:not(:disabled) {
background: #d97706;
}
}
.database-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.database-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-primary);
border-radius: 12px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
}
.status-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&.decrypted {
background: var(--primary);
color: white;
}
&.needs-update {
background: #f59e0b;
color: white;
}
&.pending {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
}
.db-info {
flex: 1;
min-width: 0;
.db-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.db-meta {
display: flex;
gap: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
}
.db-status {
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
&.decrypted {
background: rgba(34, 197, 94, 0.15);
color: #16a34a;
}
&.needs-update {
background: rgba(245, 158, 11, 0.15);
color: #b45309;
}
&.pending {
background: rgba(234, 179, 8, 0.15);
color: #b45309;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--text-tertiary);
svg {
margin-bottom: 16px;
opacity: 0.5;
}
p {
margin: 0;
font-size: 14px;
&.hint {
margin-top: 6px;
font-size: 13px;
opacity: 0.7;
}
}
}
.unavailable-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 20px;
color: var(--text-tertiary);
svg {
margin-bottom: 20px;
opacity: 0.4;
}
p {
margin: 0;
font-size: 15px;
color: var(--text-secondary);
&.hint {
margin-top: 8px;
font-size: 13px;
color: var(--text-tertiary);
}
}
}
.message-toast {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
padding: 10px 24px;
border-radius: 9999px;
font-size: 14px;
z-index: 100;
animation: slideDown 0.3s ease;
&.success {
background: var(--primary);
color: white;
}
&.error {
background: var(--danger);
color: white;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.decrypt-progress-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
.progress-card {
background: var(--bg-primary);
border-radius: 16px;
padding: 32px 40px;
min-width: 400px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.progress-file {
margin: 0 0 20px;
font-size: 14px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 9999px;
overflow: hidden;
margin-bottom: 12px;
.progress-fill {
height: 100%;
background: var(--primary);
border-radius: 9999px;
transition: width 0.2s ease;
}
}
.progress-text {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
}
}
// 图片列表样式
.current-dir {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
margin-bottom: 16px;
font-size: 13px;
.dir-label {
color: var(--text-tertiary);
flex-shrink: 0;
}
.dir-path {
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.image-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 8px;
max-height: 500px;
overflow-y: auto;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: var(--bg-tertiary);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
&:hover {
background: var(--text-tertiary);
}
}
}
.image-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--bg-primary);
border-radius: 10px;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
}
&.clickable {
cursor: pointer;
&:hover {
background: var(--bg-tertiary);
.decrypt-hint {
opacity: 1;
}
}
}
.status-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
flex-shrink: 0;
&.decrypted {
background: var(--primary);
color: white;
}
&.pending {
background: var(--bg-tertiary);
color: var(--text-tertiary);
}
.spin {
animation: spin 1s linear infinite;
}
}
.img-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.img-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.img-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.version-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
&.v3 {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
&.v4 {
background: rgba(168, 85, 247, 0.15);
color: #a855f7;
}
}
.img-size {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
.decrypt-hint {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.2s;
}
}
.more-hint {
grid-column: 1 / -1;
text-align: center;
padding: 16px;
font-size: 13px;
color: var(--text-tertiary);
}
// 账号选择器
.account-selector {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
.account-btn {
padding: 6px 14px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 13px;
border-radius: 9999px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
color: var(--primary);
}
&.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
}
}

View File

@@ -1,67 +0,0 @@
import { useEffect, useState } from 'react'
import * as configService from '../services/config'
import './DataManagementPage.scss'
function DataManagementPage() {
const [dbPath, setDbPath] = useState<string | null>(null)
const [wxid, setWxid] = useState<string | null>(null)
useEffect(() => {
const loadConfig = async () => {
const [path, id] = await Promise.all([
configService.getDbPath(),
configService.getMyWxid()
])
setDbPath(path)
setWxid(id)
}
loadConfig()
const handleChange = () => {
loadConfig()
}
window.addEventListener('wxid-changed', handleChange as EventListener)
return () => window.removeEventListener('wxid-changed', handleChange as EventListener)
}, [])
return (
<>
<div className="page-header">
<h1></h1>
</div>
<div className="page-scroll">
<section className="page-section">
<div className="section-header">
<div>
<h2>WCDB </h2>
<p className="section-desc">
WCDB DLL
</p>
</div>
</div>
<div className="database-list">
<div className="database-item decrypted">
<div className="db-info">
<div className="db-name">
</div>
<div className="db-path">{dbPath || '未配置'}</div>
</div>
</div>
<div className="database-item decrypted">
<div className="db-info">
<div className="db-name">
ID
</div>
<div className="db-path">{wxid || '未配置'}</div>
</div>
</div>
</div>
</section>
</div>
</>
)
}
export default DataManagementPage

View File

@@ -0,0 +1,171 @@
.dual-report-page {
padding: 32px 28px;
color: var(--text-primary);
}
.dual-report-page.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 12px;
color: var(--text-tertiary);
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
}
p {
margin: 8px 0 0;
color: var(--text-secondary);
}
}
.year-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 20px;
input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--text-primary);
font-size: 14px;
}
}
.ranking-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ranking-item {
display: grid;
grid-template-columns: auto auto 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 14px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
transform: translateY(-1px);
}
}
.rank-badge {
width: 28px;
height: 28px;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--border-color);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
&.top {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--primary);
}
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--primary);
font-weight: 700;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.info {
display: flex;
flex-direction: column;
gap: 4px;
.name {
font-weight: 600;
}
.sub {
font-size: 12px;
color: var(--text-tertiary);
}
}
.meta {
text-align: right;
font-size: 12px;
color: var(--text-tertiary);
.count {
font-weight: 600;
color: var(--text-primary);
}
}
.empty {
text-align: center;
color: var(--text-tertiary);
padding: 40px 0;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,138 @@
import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loader2, Search, Users } from 'lucide-react'
import './DualReportPage.scss'
interface ContactRanking {
username: string
displayName: string
avatarUrl?: string
messageCount: number
sentCount: number
receivedCount: number
lastMessageTime?: number | null
}
function DualReportPage() {
const navigate = useNavigate()
const [year, setYear] = useState<number>(0)
const [rankings, setRankings] = useState<ContactRanking[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
const [keyword, setKeyword] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
setYear(Number.isNaN(parsedYear) ? 0 : parsedYear)
}, [])
useEffect(() => {
loadRankings()
}, [])
const loadRankings = async () => {
setIsLoading(true)
setLoadError(null)
try {
const result = await window.electronAPI.analytics.getContactRankings(200)
if (result.success && result.data) {
setRankings(result.data)
} else {
setLoadError(result.error || '加载好友列表失败')
}
} catch (e) {
setLoadError(String(e))
} finally {
setIsLoading(false)
}
}
const yearLabel = year === 0 ? '全部时间' : `${year}`
const filteredRankings = useMemo(() => {
if (!keyword.trim()) return rankings
const q = keyword.trim().toLowerCase()
return rankings.filter((item) => {
return item.displayName.toLowerCase().includes(q) || item.username.toLowerCase().includes(q)
})
}, [rankings, keyword])
const handleSelect = (username: string) => {
const yearParam = year === 0 ? 0 : year
navigate(`/dual-report/view?username=${encodeURIComponent(username)}&year=${yearParam}`)
}
if (isLoading) {
return (
<div className="dual-report-page loading">
<Loader2 size={32} className="spin" />
<p>...</p>
</div>
)
}
if (loadError) {
return (
<div className="dual-report-page loading">
<p>{loadError}</p>
</div>
)
}
return (
<div className="dual-report-page">
<div className="page-header">
<div>
<h1></h1>
<p></p>
</div>
<div className="year-badge">
<Users size={14} />
<span>{yearLabel}</span>
</div>
</div>
<div className="search-bar">
<Search size={16} />
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索好友(昵称/备注/wxid"
/>
</div>
<div className="ranking-list">
{filteredRankings.map((item, index) => (
<button
key={item.username}
className="ranking-item"
onClick={() => handleSelect(item.username)}
>
<span className={`rank-badge ${index < 3 ? 'top' : ''}`}>{index + 1}</span>
<div className="avatar">
{item.avatarUrl
? <img src={item.avatarUrl} alt={item.displayName} />
: <span>{item.displayName.slice(0, 1) || '?'}</span>
}
</div>
<div className="info">
<div className="name">{item.displayName}</div>
<div className="sub">{item.username}</div>
</div>
<div className="meta">
<div className="count">{item.messageCount.toLocaleString()} </div>
<div className="hint"></div>
</div>
</button>
))}
{filteredRankings.length === 0 ? (
<div className="empty"></div>
) : null}
</div>
</div>
)
}
export default DualReportPage

View File

@@ -0,0 +1,253 @@
.annual-report-window.dual-report-window {
.hero-title {
font-size: clamp(22px, 4vw, 34px);
white-space: nowrap;
}
.dual-cover-title {
font-size: clamp(26px, 5vw, 44px);
white-space: normal;
}
.dual-names {
font-size: clamp(24px, 4vw, 40px);
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0 16px;
color: var(--ar-text-main);
.amp {
color: var(--ar-primary);
}
}
.dual-info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.dual-info-card {
background: var(--ar-card-bg);
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.05));
border-radius: 14px;
padding: 16px;
&.full {
grid-column: 1 / -1;
}
.info-label {
font-size: 12px;
color: var(--ar-text-sub);
margin-bottom: 8px;
}
.info-value {
font-size: 16px;
font-weight: 600;
color: var(--ar-text-main);
}
}
.dual-message-list {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.dual-message {
background: var(--ar-card-bg);
border-radius: 14px;
padding: 14px;
&.received {
background: var(--ar-card-bg-hover);
}
.message-meta {
font-size: 12px;
color: var(--ar-text-sub);
margin-bottom: 6px;
}
.message-content {
font-size: 14px;
color: var(--ar-text-main);
}
}
.first-chat-scene {
background: linear-gradient(180deg, #8f5b85 0%, #e38aa0 50%, #f6d0c8 100%);
border-radius: 20px;
padding: 28px 24px 24px;
color: #fff;
position: relative;
overflow: hidden;
margin-top: 16px;
}
.first-chat-scene::before {
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.2), transparent 40%),
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.15), transparent 35%),
radial-gradient(circle at 50% 80%, rgba(255, 255, 255, 0.12), transparent 45%);
opacity: 0.6;
pointer-events: none;
}
.scene-title {
font-size: 24px;
font-weight: 700;
text-align: center;
margin-bottom: 8px;
}
.scene-subtitle {
font-size: 18px;
font-weight: 500;
text-align: center;
margin-bottom: 20px;
opacity: 0.95;
}
.scene-messages {
display: flex;
flex-direction: column;
gap: 14px;
}
.scene-message {
display: flex;
align-items: flex-end;
gap: 12px;
&.sent {
flex-direction: row-reverse;
}
}
.scene-avatar {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #fff;
}
.scene-bubble {
background: rgba(255, 255, 255, 0.85);
color: #5a4d5e;
padding: 10px 14px;
border-radius: 14px;
max-width: 60%;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}
.scene-message.sent .scene-bubble {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
}
.scene-meta {
font-size: 11px;
opacity: 0.7;
margin-bottom: 4px;
}
.scene-content {
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.scene-message.sent .scene-avatar {
background: rgba(255, 224, 168, 0.9);
color: #4a3a2f;
}
.dual-stat-grid {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 14px;
margin: 20px -28px 24px;
padding: 0 28px;
overflow: visible;
}
.dual-stat-card {
background: var(--ar-card-bg);
border-radius: 14px;
padding: 14px 12px;
text-align: center;
}
.stat-num {
font-size: clamp(20px, 2.8vw, 30px);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.stat-unit {
font-size: 12px;
}
.dual-stat-card.long .stat-num {
font-size: clamp(18px, 2.4vw, 26px);
letter-spacing: -0.02em;
}
.emoji-row {
display: grid;
grid-template-columns: repeat(2, minmax(260px, 1fr));
gap: 20px;
margin: 0 -12px;
}
.emoji-card {
border: 1px solid var(--bg-tertiary, rgba(0, 0, 0, 0.08));
border-radius: 16px;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
background: var(--ar-card-bg);
img {
width: 64px;
height: 64px;
object-fit: contain;
}
}
.emoji-title {
font-size: 12px;
color: var(--ar-text-sub);
}
.emoji-placeholder {
font-size: 12px;
color: var(--ar-text-sub);
word-break: break-all;
text-align: center;
}
.word-cloud-empty {
color: var(--ar-text-sub);
font-size: 14px;
text-align: center;
padding: 24px 0;
}
}

View File

@@ -0,0 +1,472 @@
import { useEffect, useState, type CSSProperties } from 'react'
import './AnnualReportWindow.scss'
import './DualReportWindow.scss'
interface DualReportMessage {
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}
interface DualReportData {
year: number
selfName: string
friendUsername: string
friendName: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
} | null
firstChatMessages?: DualReportMessage[]
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: DualReportMessage[]
} | null
stats: {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
}
topPhrases: Array<{ phrase: string; count: number }>
}
const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => {
if (!words || words.length === 0) {
return <div className="word-cloud-empty"></div>
}
const sortedWords = [...words].sort((a, b) => b.count - a.count)
const maxCount = sortedWords.length > 0 ? sortedWords[0].count : 1
const topWords = sortedWords.slice(0, 32)
const baseSize = 520
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
function DualReportWindow() {
const [reportData, setReportData] = useState<DualReportData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [loadingStage, setLoadingStage] = useState('准备中')
const [loadingProgress, setLoadingProgress] = useState(0)
const [myEmojiUrl, setMyEmojiUrl] = useState<string | null>(null)
const [friendEmojiUrl, setFriendEmojiUrl] = useState<string | null>(null)
useEffect(() => {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const username = params.get('username')
const yearParam = params.get('year')
const parsedYear = yearParam ? parseInt(yearParam, 10) : 0
const year = Number.isNaN(parsedYear) ? 0 : parsedYear
if (!username) {
setError('缺少好友信息')
setIsLoading(false)
return
}
generateReport(username, year)
}, [])
const generateReport = async (friendUsername: string, year: number) => {
setIsLoading(true)
setError(null)
setLoadingProgress(0)
const removeProgressListener = window.electronAPI.dualReport.onProgress?.((payload: { status: string; progress: number }) => {
setLoadingProgress(payload.progress)
setLoadingStage(payload.status)
})
try {
const result = await window.electronAPI.dualReport.generateReport({ friendUsername, year })
removeProgressListener?.()
setLoadingProgress(100)
setLoadingStage('完成')
if (result.success && result.data) {
setReportData(result.data)
setIsLoading(false)
} else {
setError(result.error || '生成报告失败')
setIsLoading(false)
}
} catch (e) {
removeProgressListener?.()
setError(String(e))
setIsLoading(false)
}
}
useEffect(() => {
const loadEmojis = async () => {
if (!reportData) return
const stats = reportData.stats
if (stats.myTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.myTopEmojiUrl, stats.myTopEmojiMd5)
if (res.success && res.localPath) {
setMyEmojiUrl(res.localPath)
}
}
if (stats.friendTopEmojiUrl) {
const res = await window.electronAPI.chat.downloadEmoji(stats.friendTopEmojiUrl, stats.friendTopEmojiMd5)
if (res.success && res.localPath) {
setFriendEmojiUrl(res.localPath)
}
}
}
void loadEmojis()
}, [reportData])
if (isLoading) {
return (
<div className="annual-report-window loading">
<div className="loading-ring">
<svg viewBox="0 0 100 100">
<circle className="ring-bg" cx="50" cy="50" r="42" />
<circle
className="ring-progress"
cx="50" cy="50" r="42"
style={{ strokeDashoffset: 264 - (264 * loadingProgress / 100) }}
/>
</svg>
<span className="ring-text">{loadingProgress}%</span>
</div>
<p className="loading-stage">{loadingStage}</p>
<p className="loading-hint"></p>
</div>
)
}
if (error) {
return (
<div className="annual-report-window error">
<p>: {error}</p>
</div>
)
}
if (!reportData) {
return (
<div className="annual-report-window error">
<p></p>
</div>
)
}
const yearTitle = reportData.year === 0 ? '全部时间' : `${reportData.year}`
const firstChat = reportData.firstChat
const firstChatMessages = (reportData.firstChatMessages && reportData.firstChatMessages.length > 0)
? reportData.firstChatMessages.slice(0, 3)
: firstChat
? [{
content: firstChat.content,
isSentByMe: firstChat.isSentByMe,
createTime: firstChat.createTime,
createTimeStr: firstChat.createTimeStr
}]
: []
const daysSince = firstChat
? Math.max(0, Math.floor((Date.now() - firstChat.createTime) / 86400000))
: null
const yearFirstChat = reportData.yearFirstChat
const stats = reportData.stats
const statItems = [
{ label: '总消息数', value: stats.totalMessages },
{ label: '总字数', value: stats.totalWords },
{ label: '图片', value: stats.imageCount },
{ label: '语音', value: stats.voiceCount },
{ label: '表情', value: stats.emojiCount },
]
const decodeEntities = (text: string) => (
text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
)
const stripCdata = (text: string) => text.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
const extractXmlText = (content: string) => {
const titleMatch = content.match(/<title>([\s\S]*?)<\/title>/i)
if (titleMatch?.[1]) return titleMatch[1]
const descMatch = content.match(/<des>([\s\S]*?)<\/des>/i)
if (descMatch?.[1]) return descMatch[1]
const summaryMatch = content.match(/<summary>([\s\S]*?)<\/summary>/i)
if (summaryMatch?.[1]) return summaryMatch[1]
const contentMatch = content.match(/<content>([\s\S]*?)<\/content>/i)
if (contentMatch?.[1]) return contentMatch[1]
return ''
}
const formatMessageContent = (content?: string) => {
const raw = String(content || '').trim()
if (!raw) return '(空)'
const hasXmlTag = /<\s*[a-zA-Z]+[^>]*>/.test(raw)
const looksLikeXml = /<\?xml|<msg\b|<appmsg\b|<sysmsg\b|<appattach\b|<emoji\b|<img\b|<voip\b/i.test(raw)
|| hasXmlTag
if (!looksLikeXml) return raw
const extracted = extractXmlText(raw)
if (!extracted) return 'XML消息'
return decodeEntities(stripCdata(extracted).trim()) || 'XML消息'
}
const formatFullDate = (timestamp: number) => {
const d = new Date(timestamp)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
return `${year}/${month}/${day} ${hour}:${minute}`
}
return (
<div className="annual-report-window dual-report-window">
<div className="drag-region" />
<div className="bg-decoration">
<div className="deco-circle c1" />
<div className="deco-circle c2" />
<div className="deco-circle c3" />
<div className="deco-circle c4" />
<div className="deco-circle c5" />
</div>
<div className="report-scroll-view">
<div className="report-container">
<section className="section">
<div className="label-text">WEFLOW · DUAL REPORT</div>
<h1 className="hero-title dual-cover-title">{yearTitle}<br /></h1>
<hr className="divider" />
<div className="dual-names">
<span>{reportData.selfName}</span>
<span className="amp">&amp;</span>
<span>{reportData.friendName}</span>
</div>
<p className="hero-desc"></p>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
{firstChat ? (
<>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(firstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{daysSince} </div>
</div>
</div>
{firstChatMessages.length > 0 ? (
<div className="dual-message-list">
{firstChatMessages.map((msg, idx) => (
<div
key={idx}
className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}
>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
) : null}
</>
) : (
<p className="hero-desc"></p>
)}
</section>
{yearFirstChat ? (
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">
{reportData.year === 0 ? '你们的第一段对话' : `${reportData.year}年的第一段对话`}
</h2>
<div className="dual-info-grid">
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{formatFullDate(yearFirstChat.createTime)}</div>
</div>
<div className="dual-info-card">
<div className="info-label"></div>
<div className="info-value">{yearFirstChat.isSentByMe ? reportData.selfName : reportData.friendName}</div>
</div>
</div>
<div className="dual-message-list">
{yearFirstChat.firstThreeMessages.map((msg, idx) => (
<div key={idx} className={`dual-message ${msg.isSentByMe ? 'sent' : 'received'}`}>
<div className="message-meta">
{msg.isSentByMe ? reportData.selfName : reportData.friendName} · {formatFullDate(msg.createTime)}
</div>
<div className="message-content">{formatMessageContent(msg.content)}</div>
</div>
))}
</div>
</section>
) : null}
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<WordCloud words={reportData.topPhrases} />
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title">{yearTitle}</h2>
<div className="dual-stat-grid">
{statItems.map((item) => {
const valueText = item.value.toLocaleString()
const isLong = valueText.length > 7
return (
<div key={item.label} className={`dual-stat-card ${isLong ? 'long' : ''}`}>
<div className="stat-num">{valueText}</div>
<div className="stat-unit">{item.label}</div>
</div>
)
})}
</div>
<div className="emoji-row">
<div className="emoji-card">
<div className="emoji-title"></div>
{myEmojiUrl ? (
<img src={myEmojiUrl} alt="my-emoji" />
) : (
<div className="emoji-placeholder">{stats.myTopEmojiMd5 || '暂无'}</div>
)}
</div>
<div className="emoji-card">
<div className="emoji-title">{reportData.friendName}</div>
{friendEmojiUrl ? (
<img src={friendEmojiUrl} alt="friend-emoji" />
) : (
<div className="emoji-placeholder">{stats.friendTopEmojiMd5 || '暂无'}</div>
)}
</div>
</div>
</section>
<section className="section">
<div className="label-text"></div>
<h2 className="hero-title"></h2>
<p className="hero-desc"></p>
</section>
</div>
</div>
</div>
)
}
export default DualReportWindow

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
exportMedia: boolean exportMedia: boolean
exportImages: boolean exportImages: boolean
exportVoices: boolean exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean exportEmojis: boolean
exportVoiceAsText: boolean exportVoiceAsText: boolean
excelCompactColumns: boolean excelCompactColumns: boolean
@@ -65,6 +66,7 @@ function ExportPage() {
exportMedia: false, exportMedia: false,
exportImages: true, exportImages: true,
exportVoices: true, exportVoices: true,
exportVideos: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true, exportVoiceAsText: true,
excelCompactColumns: true, excelCompactColumns: true,
@@ -187,7 +189,7 @@ function ExportPage() {
}, [loadSessions]) }, [loadSessions])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.export.onProgress?.((payload) => { const removeListener = window.electronAPI.export.onProgress?.((payload: { current: number; total: number; currentSession: string; phase: string }) => {
setExportProgress({ setExportProgress({
current: payload.current, current: payload.current,
total: payload.total, total: payload.total,
@@ -257,6 +259,7 @@ function ExportPage() {
exportMedia: true, exportMedia: true,
exportImages: true, exportImages: true,
exportVoices: true, exportVoices: true,
exportVideos: true,
exportEmojis: true, exportEmojis: true,
exportVoiceAsText: true exportVoiceAsText: true
} }
@@ -286,6 +289,7 @@ function ExportPage() {
exportMedia: options.exportMedia, exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages, exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices, exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis, exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容 exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns, excelCompactColumns: options.excelCompactColumns,
@@ -609,7 +613,7 @@ function ExportPage() {
)} )}
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<p className="setting-subtitle">//</p> <p className="setting-subtitle">///</p>
<div className="media-options-card"> <div className="media-options-card">
<div className="media-switch-row"> <div className="media-switch-row">
<div className="media-switch-info"> <div className="media-switch-info">
@@ -661,7 +665,7 @@ function ExportPage() {
<label className="media-checkbox-row"> <label className="media-checkbox-row">
<div className="media-checkbox-info"> <div className="media-checkbox-info">
<span className="media-checkbox-title"></span> <span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span> <span className="media-checkbox-desc"></span>
</div> </div>
<input <input
type="checkbox" type="checkbox"
@@ -672,6 +676,21 @@ function ExportPage() {
<div className="media-option-divider"></div> <div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}> <label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info"> <div className="media-checkbox-info">
<span className="media-checkbox-title"></span> <span className="media-checkbox-title"></span>

View File

@@ -333,7 +333,7 @@
.group-avatar { .group-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@@ -346,11 +346,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -390,7 +390,7 @@
.skeleton-avatar { .skeleton-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
border-radius: 50%; border-radius: 8px;
background: var(--bg-tertiary); background: var(--bg-tertiary);
animation: pulse 1.5s infinite; animation: pulse 1.5s infinite;
} }
@@ -500,7 +500,7 @@
.group-avatar.large { .group-avatar.large {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 10px;
overflow: hidden; overflow: hidden;
margin: 0 auto 16px; margin: 0 auto 16px;
@@ -513,11 +513,11 @@
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); background: var(--bg-tertiary);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #fff; color: var(--text-secondary);
} }
} }
@@ -656,6 +656,32 @@
cursor: not-allowed; cursor: not-allowed;
} }
} }
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: none;
background: var(--bg-tertiary);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
-webkit-app-region: no-drag;
font-size: 12px;
flex-shrink: 0;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
} }
.content-body { .content-body {

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } 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, Download } from 'lucide-react'
import { Avatar } from '../components/Avatar' 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'
@@ -16,6 +16,10 @@ interface GroupMember {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
} }
interface GroupMessageRank { interface GroupMessageRank {
@@ -39,6 +43,7 @@ function GroupAnalyticsPage() {
const [activeHours, setActiveHours] = useState<Record<number, number>>({}) const [activeHours, setActiveHours] = useState<Record<number, number>>({})
const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null) const [mediaStats, setMediaStats] = useState<{ typeCounts: Array<{ type: number; name: string; count: number }>; total: number } | null>(null)
const [functionLoading, setFunctionLoading] = useState(false) const [functionLoading, setFunctionLoading] = useState(false)
const [isExportingMembers, setIsExportingMembers] = useState(false)
// 成员详情弹框 // 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null) const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
@@ -181,6 +186,10 @@ function GroupAnalyticsPage() {
return num.toLocaleString() return num.toLocaleString()
} }
const sanitizeFileName = (name: string) => {
return name.replace(/[<>:"/\\|?*]+/g, '_').trim()
}
const getHourlyOption = () => { const getHourlyOption = () => {
const hours = Array.from({ length: 24 }, (_, i) => i) const hours = Array.from({ length: 24 }, (_, i) => i)
const data = hours.map(h => activeHours[h] || 0) const data = hours.map(h => activeHours[h] || 0)
@@ -252,6 +261,35 @@ function GroupAnalyticsPage() {
setCopiedField(null) setCopiedField(null)
} }
const handleExportMembers = async () => {
if (!selectedGroup || isExportingMembers) return
setIsExportingMembers(true)
try {
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
const baseName = sanitizeFileName(`${selectedGroup.displayName || selectedGroup.username}_群成员列表`)
const separator = downloadsPath && downloadsPath.includes('\\') ? '\\' : '/'
const defaultPath = downloadsPath ? `${downloadsPath}${separator}${baseName}.xlsx` : `${baseName}.xlsx`
const saveResult = await window.electronAPI.dialog.saveFile({
title: '导出群成员列表',
defaultPath,
filters: [{ name: 'Excel', extensions: ['xlsx'] }]
})
if (!saveResult || saveResult.canceled || !saveResult.filePath) return
const result = await window.electronAPI.groupAnalytics.exportGroupMembers(selectedGroup.username, saveResult.filePath)
if (result.success) {
alert(`导出成功,共 ${result.count ?? members.length}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出群成员失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMembers(false)
}
}
const handleCopy = async (text: string, field: string) => { const handleCopy = async (text: string, field: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
@@ -264,6 +302,10 @@ function GroupAnalyticsPage() {
const renderMemberModal = () => { const renderMemberModal = () => {
if (!selectedMember) return null if (!selectedMember) return null
const nickname = (selectedMember.nickname || '').trim()
const alias = (selectedMember.alias || '').trim()
const remark = (selectedMember.remark || '').trim()
const groupNickname = (selectedMember.groupNickname || '').trim()
return ( return (
<div className="member-modal-overlay" onClick={() => setSelectedMember(null)}> <div className="member-modal-overlay" onClick={() => setSelectedMember(null)}>
@@ -286,11 +328,40 @@ function GroupAnalyticsPage() {
</div> </div>
<div className="detail-row"> <div className="detail-row">
<span className="detail-label"></span> <span className="detail-label"></span>
<span className="detail-value">{selectedMember.displayName}</span> <span className="detail-value">{nickname || '未设置'}</span>
<button className="copy-btn" onClick={() => handleCopy(selectedMember.displayName, 'displayName')}> {nickname && (
{copiedField === 'displayName' ? <Check size={14} /> : <Copy size={14} />} <button className="copy-btn" onClick={() => handleCopy(nickname, 'nickname')}>
{copiedField === 'nickname' ? <Check size={14} /> : <Copy size={14} />}
</button>
)}
</div>
{alias && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{alias}</span>
<button className="copy-btn" onClick={() => handleCopy(alias, 'alias')}>
{copiedField === 'alias' ? <Check size={14} /> : <Copy size={14} />}
</button> </button>
</div> </div>
)}
{groupNickname && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{groupNickname}</span>
<button className="copy-btn" onClick={() => handleCopy(groupNickname, 'groupNickname')}>
{copiedField === 'groupNickname' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
{remark && (
<div className="detail-row">
<span className="detail-label"></span>
<span className="detail-value">{remark}</span>
<button className="copy-btn" onClick={() => handleCopy(remark, 'remark')}>
{copiedField === 'remark' ? <Check size={14} /> : <Copy size={14} />}
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -423,6 +494,12 @@ function GroupAnalyticsPage() {
onRangeComplete={handleDateRangeComplete} onRangeComplete={handleDateRangeComplete}
/> />
)} )}
{selectedFunction === 'members' && (
<button className="export-btn" onClick={handleExportMembers} disabled={functionLoading || isExportingMembers}>
{isExportingMembers ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span></span>
</button>
)}
<button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}> <button className="refresh-btn" onClick={handleRefresh} disabled={functionLoading}>
<RefreshCw size={16} className={functionLoading ? 'spin' : ''} /> <RefreshCw size={16} className={functionLoading ? 'spin' : ''} />
</button> </button>

View File

@@ -0,0 +1,99 @@
.image-window-container {
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
overflow: hidden;
user-select: none;
.title-bar {
height: 40px;
min-height: 40px;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
padding-right: 140px; // 为原生窗口控件留出空间
.window-drag-area {
flex: 1;
height: 100%;
-webkit-app-region: drag;
}
.title-bar-controls {
display: flex;
align-items: center;
gap: 8px;
-webkit-app-region: no-drag;
margin-right: 16px;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}
}
.image-viewport {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
cursor: grab;
&:active {
cursor: grabbing;
}
img {
max-width: none;
max-height: none;
object-fit: contain;
will-change: transform;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
pointer-events: auto;
}
}
}
.image-window-empty {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
background-color: var(--bg-primary);
}

162
src/pages/ImageWindow.tsx Normal file
View File

@@ -0,0 +1,162 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import './ImageWindow.scss'
export default function ImageWindow() {
const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath')
const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [initialScale, setInitialScale] = useState(1)
const viewportRef = useRef<HTMLDivElement>(null)
// 使用 ref 存储拖动状态,避免闭包问题
const dragStateRef = useRef({
isDragging: false,
startX: 0,
startY: 0,
startPosX: 0,
startPosY: 0
})
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
const handleRotate = () => setRotation(prev => (prev + 90) % 360)
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
// 重置视图
const handleReset = useCallback(() => {
setScale(1)
setRotation(0)
setPosition({ x: 0, y: 0 })
}, [])
// 图片加载完成后计算初始缩放
const handleImageLoad = useCallback((e: React.SyntheticEvent<HTMLImageElement>) => {
const img = e.currentTarget
const naturalWidth = img.naturalWidth
const naturalHeight = img.naturalHeight
if (viewportRef.current) {
const viewportWidth = viewportRef.current.clientWidth * 0.9
const viewportHeight = viewportRef.current.clientHeight * 0.9
const scaleX = viewportWidth / naturalWidth
const scaleY = viewportHeight / naturalHeight
const fitScale = Math.min(scaleX, scaleY, 1)
setInitialScale(fitScale)
setScale(1)
}
}, [])
// 使用原生事件监听器处理拖动
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragStateRef.current.isDragging) return
const dx = e.clientX - dragStateRef.current.startX
const dy = e.clientY - dragStateRef.current.startY
setPosition({
x: dragStateRef.current.startPosX + dx,
y: dragStateRef.current.startPosY + dy
})
}
const handleMouseUp = () => {
dragStateRef.current.isDragging = false
document.body.style.cursor = ''
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
}, [])
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return
e.preventDefault()
dragStateRef.current = {
isDragging: true,
startX: e.clientX,
startY: e.clientY,
startPosX: position.x,
startPosY: position.y
}
document.body.style.cursor = 'grabbing'
}
const handleWheel = useCallback((e: React.WheelEvent) => {
const delta = -Math.sign(e.deltaY) * 0.15
setScale(prev => Math.min(Math.max(prev + delta, 0.1), 10))
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
handleReset()
}, [handleReset])
// 快捷键支持
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close()
if (e.key === '=' || e.key === '+') handleZoomIn()
if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset])
if (!imagePath) {
return (
<div className="image-window-empty">
<span></span>
</div>
)
}
const displayScale = initialScale * scale
return (
<div className="image-window-container">
<div className="title-bar">
<div className="window-drag-area"></div>
<div className="title-bar-controls">
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
<div className="divider"></div>
<button onClick={handleRotateCcw} title="逆时针旋转"><RotateCcw size={16} /></button>
<button onClick={handleRotate} title="顺时针旋转 (R)"><RotateCw size={16} /></button>
</div>
</div>
<div
className="image-viewport"
ref={viewportRef}
onWheel={handleWheel}
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
>
<img
src={imagePath}
alt="Preview"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}}
onLoad={handleImageLoad}
draggable={false}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,54 @@
@keyframes noti-enter {
0% {
opacity: 0;
transform: translateY(-20px) scale(0.96);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes noti-exit {
0% {
opacity: 1;
transform: scale(1) translateY(0);
filter: blur(0);
}
100% {
opacity: 0;
transform: scale(0.92) translateY(4px);
filter: blur(2px);
}
}
body {
// Ensure the body background is transparent to let the rounded corners show
background: transparent;
overflow: hidden;
margin: 0;
padding: 0;
}
#notification-root {
// Ensure the container allows 3D transforms
perspective: 1000px;
}
#notification-current {
// New notification slides in
animation: noti-enter 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
will-change: transform, opacity;
}
#notification-prev {
// Old notification scales out
animation: noti-exit 0.35s cubic-bezier(0.33, 1, 0.68, 1) forwards;
transform-origin: center top;
will-change: transform, opacity, filter;
// Ensure it stays behind
z-index: 0 !important;
}

View File

@@ -0,0 +1,165 @@
import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import '../components/NotificationToast.scss'
import './NotificationWindow.scss'
export default function NotificationWindow() {
const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
// We need a ref to access the current notification inside the callback
// without satisfying the dependency array which would recreate the listener
// Actually, setNotification(prev => ...) pattern is better, but we need the VALUE of current to set as prev.
// So we use setNotification callback: setNotification(current => { ... return newNode })
// But we need to update TWO states.
// So we use a ref to track "current displayed" for the event handler.
// Or just use functional updates, but we need to setPrev(current).
const notificationRef = useRef<NotificationData | null>(null)
useEffect(() => {
notificationRef.current = notification
}, [notification])
useEffect(() => {
const handleShow = (_event: any, data: any) => {
// data: { title, content, avatarUrl, sessionId }
const timestamp = Math.floor(Date.now() / 1000)
const newNoti: NotificationData = {
id: `noti_${timestamp}_${Math.random().toString(36).substr(2, 9)}`,
sessionId: data.sessionId,
title: data.title,
content: data.content,
timestamp: timestamp,
avatarUrl: data.avatarUrl
}
// Set previous to current (ref)
if (notificationRef.current) {
setPrevNotification(notificationRef.current)
}
setNotification(newNoti)
}
if (window.electronAPI) {
const remove = window.electronAPI.notification?.onShow?.(handleShow)
window.electronAPI.notification?.ready?.()
return () => remove?.()
}
}, [])
// Clean up prevNotification after transition
useEffect(() => {
if (prevNotification) {
const timer = setTimeout(() => {
setPrevNotification(null)
}, 400)
return () => clearTimeout(timer)
}
}, [prevNotification])
const handleClose = () => {
setNotification(null)
setPrevNotification(null)
window.electronAPI.notification?.close()
}
const handleClick = (sessionId: string) => {
window.electronAPI.notification?.click(sessionId)
setNotification(null)
setPrevNotification(null)
// Main process handles window hide/close
}
useEffect(() => {
// Measure only if we have a notification (current or prev)
if (!notification && !prevNotification) return
// Prefer measuring the NEW one
const targetId = notification ? 'notification-current' : 'notification-prev'
const timer = setTimeout(() => {
// Find the wrapper of the content
// Since we wrap them, we should measure the content inside
// But getting root is easier if size is set by relative child
const root = document.getElementById('notification-root')
if (root) {
const height = root.offsetHeight
const width = 344
if (window.electronAPI?.notification?.resize) {
const finalHeight = Math.min(height + 4, 300)
window.electronAPI.notification.resize(width, finalHeight)
}
}
}, 50)
return () => clearTimeout(timer)
}, [notification, prevNotification])
if (!notification && !prevNotification) return null
return (
<div
id="notification-root"
style={{
width: '100vw',
height: 'auto',
minHeight: '10px',
background: 'transparent',
position: 'relative', // Context for absolute children
overflow: 'hidden', // Prevent scrollbars during transition
padding: '2px', // Margin safe
boxSizing: 'border-box'
}}>
{/* Previous Notification (Background / Fading Out) */}
{prevNotification && (
<div
id="notification-prev"
key={prevNotification.id}
style={{
position: 'absolute',
top: 2, // Match padding
left: 2,
width: 'calc(100% - 4px)', // Match width logic
zIndex: 1,
pointerEvents: 'none' // Disable interaction on old one
}}
>
<NotificationToast
key={prevNotification.id}
data={prevNotification}
onClose={() => { }} // No-op for background item
onClick={() => { }}
position="top-right"
isStatic={true}
initialVisible={true}
/>
</div>
)}
{/* Current Notification (Foreground / Fading In) */}
{notification && (
<div
id="notification-current"
key={notification.id}
style={{
position: 'relative', // Takes up space
zIndex: 2,
width: '100%'
}}
>
<NotificationToast
key={notification.id} // Ensure remount for animation
data={notification}
onClose={handleClose}
onClick={handleClick}
position="top-right"
isStatic={true}
initialVisible={true}
/>
</div>
)}
</div>
)
}

View File

@@ -180,7 +180,7 @@
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
input { input:not(.filter-search-box input) {
width: 100%; width: 100%;
padding: 10px 16px; padding: 10px 16px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -207,6 +207,7 @@
select { select {
width: 100%; width: 100%;
padding: 10px 16px; padding: 10px 16px;
padding-right: 36px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 9999px; border-radius: 9999px;
font-size: 14px; font-size: 14px;
@@ -214,6 +215,9 @@
color: var(--text-primary); color: var(--text-primary);
margin-bottom: 10px; margin-bottom: 10px;
cursor: pointer; cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
&:focus { &:focus {
outline: none; outline: none;
@@ -221,6 +225,124 @@
} }
} }
.select-wrapper {
position: relative;
margin-bottom: 10px;
select {
margin-bottom: 0;
}
>svg {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-tertiary);
pointer-events: none;
}
}
// 自定义下拉选择框
.custom-select {
position: relative;
margin-bottom: 10px;
}
.custom-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.custom-select-value {
flex: 1;
}
.custom-select-arrow {
color: var(--text-tertiary);
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
.custom-select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
z-index: 100;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
// 展开收起动画
opacity: 0;
visibility: hidden;
transform: translateY(-8px) scaleY(0.95);
transform-origin: top center;
transition: all 0.2s cubic-bezier(0.2, 0, 0.2, 1);
&.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scaleY(1);
}
}
.custom-select-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--bg-tertiary);
}
&.selected {
color: var(--primary);
font-weight: 500;
svg {
color: var(--primary);
}
}
svg {
flex-shrink: 0;
}
}
.select-field { .select-field {
position: relative; position: relative;
margin-bottom: 10px; margin-bottom: 10px;
@@ -1265,3 +1387,172 @@
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
} }
// 通知过滤双列表容器
.notification-filter-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 12px;
}
.filter-panel {
display: flex;
flex-direction: column;
background: var(--bg-tertiary);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.filter-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
>span {
flex-shrink: 0;
}
}
.filter-search-box {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
max-width: 140px;
padding: 4px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s;
&:focus-within {
border-color: var(--primary);
background: var(--bg-primary);
}
svg {
flex-shrink: 0;
width: 12px;
height: 12px;
color: var(--text-tertiary);
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
font-size: 12px;
color: var(--text-primary);
outline: none;
&::placeholder {
color: var(--text-tertiary);
}
}
}
.filter-panel-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 600;
color: white;
background: var(--primary);
border-radius: 10px;
}
.filter-panel-list {
flex: 1;
min-height: 200px;
max-height: 300px;
overflow-y: auto;
padding: 8px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.filter-panel-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin-bottom: 4px;
background: var(--bg-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
&:last-child {
margin-bottom: 0;
}
&:hover {
background: var(--bg-secondary);
.filter-item-action {
opacity: 1;
color: var(--primary);
}
}
&.selected {
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 20%, transparent);
&:hover .filter-item-action {
color: var(--danger);
}
}
.filter-item-name {
flex: 1;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-item-action {
font-size: 18px;
font-weight: 500;
color: var(--text-tertiary);
opacity: 0.5;
transition: all 0.15s;
}
}
.filter-panel-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 100px;
font-size: 13px;
color: var(--text-tertiary);
}

View File

@@ -9,14 +9,16 @@ import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, RotateCcw, Trash2, Plug, Check, Sun, Moon,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound ShieldCheck, Fingerprint, Lock, KeyRound, Bell
} from 'lucide-react' } from 'lucide-react'
import { Avatar } from '../components/Avatar'
import './SettingsPage.scss' import './SettingsPage.scss'
type SettingsTab = 'appearance' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | 'about' type SettingsTab = 'appearance' | 'notification' | 'database' | 'whisper' | 'export' | 'cache' | 'security' | '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: 'notification', label: '通知', icon: Bell },
{ id: 'database', label: '数据库连接', icon: Database }, { id: 'database', label: '数据库连接', icon: Database },
{ id: 'whisper', label: '语音识别模型', icon: Mic }, { id: 'whisper', label: '语音识别模型', icon: Mic },
{ id: 'export', label: '导出', icon: Download }, { id: 'export', label: '导出', icon: Download },
@@ -25,6 +27,7 @@ const tabs: { id: SettingsTab; label: string; icon: React.ElementType }[] = [
{ id: 'about', label: '关于', icon: Info } { id: 'about', label: '关于', icon: Info }
] ]
interface WxidOption { interface WxidOption {
wxid: string wxid: string
modifiedTime: number modifiedTime: number
@@ -62,9 +65,11 @@ function SettingsPage() {
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false) const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false) const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false) const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null) const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null) const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null) const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false) const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base') const [whisperModelName, setWhisperModelName] = useState('base')
@@ -81,6 +86,18 @@ function SettingsPage() {
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true) const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2) const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
const [notificationEnabled, setNotificationEnabled] = useState(true)
const [notificationPosition, setNotificationPosition] = useState<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'>('top-right')
const [notificationFilterMode, setNotificationFilterMode] = useState<'all' | 'whitelist' | 'blacklist'>('all')
const [notificationFilterList, setNotificationFilterList] = useState<string[]>([])
const [filterSearchKeyword, setFilterSearchKeyword] = useState('')
const [filterModeDropdownOpen, setFilterModeDropdownOpen] = useState(false)
const [positionDropdownOpen, setPositionDropdownOpen] = 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)
@@ -96,6 +113,7 @@ function SettingsPage() {
const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false) const [isClearingAnalyticsCache, setIsClearingAnalyticsCache] = useState(false)
const [isClearingImageCache, setIsClearingImageCache] = useState(false) const [isClearingImageCache, setIsClearingImageCache] = useState(false)
const [isClearingAllCache, setIsClearingAllCache] = useState(false) const [isClearingAllCache, setIsClearingAllCache] = useState(false)
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
// 安全设置 state // 安全设置 state
const [authEnabled, setAuthEnabled] = useState(false) const [authEnabled, setAuthEnabled] = useState(false)
@@ -125,6 +143,9 @@ function SettingsPage() {
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
loadAppVersion() loadAppVersion()
return () => {
Object.values(saveTimersRef.current).forEach((timer) => clearTimeout(timer))
}
}, []) }, [])
// 点击外部关闭下拉框 // 点击外部关闭下拉框
@@ -140,16 +161,19 @@ function SettingsPage() {
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) { if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
} }
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
}
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect]) }, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {
@@ -158,6 +182,24 @@ function SettingsPage() {
} }
}, []) }, [])
// 点击外部关闭自定义下拉框
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.custom-select')) {
setFilterModeDropdownOpen(false)
setPositionDropdownOpen(false)
}
}
if (filterModeDropdownOpen || positionDropdownOpen) {
document.addEventListener('click', handleClickOutside)
}
return () => {
document.removeEventListener('click', handleClickOutside)
}
}, [filterModeDropdownOpen, positionDropdownOpen])
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const savedKey = await configService.getDecryptKey() const savedKey = await configService.getDecryptKey()
@@ -179,6 +221,11 @@ function SettingsPage() {
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns() const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency() const savedExportDefaultConcurrency = await configService.getExportDefaultConcurrency()
const savedNotificationEnabled = await configService.getNotificationEnabled()
const savedNotificationPosition = await configService.getNotificationPosition()
const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedAuthEnabled = await configService.getAuthEnabled() const savedAuthEnabled = await configService.getAuthEnabled()
const savedAuthUseHello = await configService.getAuthUseHello() const savedAuthUseHello = await configService.getAuthUseHello()
setAuthEnabled(savedAuthEnabled) setAuthEnabled(savedAuthEnabled)
@@ -212,6 +259,11 @@ function SettingsPage() {
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true) setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2) setExportDefaultConcurrency(savedExportDefaultConcurrency ?? 2)
setNotificationEnabled(savedNotificationEnabled)
setNotificationPosition(savedNotificationPosition)
setNotificationFilterMode(savedNotificationFilterMode)
setNotificationFilterList(savedNotificationFilterList)
// 如果语言列表为空,保存默认值 // 如果语言列表为空,保存默认值
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) { if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
const defaultLanguages = ['zh'] const defaultLanguages = ['zh']
@@ -261,7 +313,7 @@ function SettingsPage() {
}, []) }, [])
useEffect(() => { useEffect(() => {
const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload) => { const removeListener = window.electronAPI.whisper?.onDownloadProgress?.((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (typeof payload.percent === 'number') { if (typeof payload.percent === 'number') {
setWhisperDownloadProgress(payload.percent) setWhisperDownloadProgress(payload.percent)
} }
@@ -307,6 +359,19 @@ function SettingsPage() {
} }
} }
const handleIgnoreUpdate = async () => {
if (!updateInfo || !updateInfo.version) return
try {
await window.electronAPI.app.ignoreUpdate(updateInfo.version)
setShowUpdateDialog(false)
setUpdateInfo(null)
showMessage(`已忽略版本 ${updateInfo.version}`, true)
} catch (e: any) {
showMessage(`操作失败: ${e}`, false)
}
}
const showMessage = (text: string, success: boolean) => { const showMessage = (text: string, success: boolean) => {
@@ -334,6 +399,12 @@ function SettingsPage() {
imageAesKey: imageAesKey || '' imageAesKey: imageAesKey || ''
}) })
const buildKeysFromInputs = (overrides?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string }): WxidKeys => ({
decryptKey: overrides?.decryptKey ?? decryptKey ?? '',
imageXorKey: parseImageXorKey(overrides?.imageXorKey ?? imageXorKey),
imageAesKey: overrides?.imageAesKey ?? imageAesKey ?? ''
})
const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({ const buildKeysFromConfig = (wxidConfig: configService.WxidConfig | null): WxidKeys => ({
decryptKey: wxidConfig?.decryptKey || '', decryptKey: wxidConfig?.decryptKey || '',
imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null, imageXorKey: typeof wxidConfig?.imageXorKey === 'number' ? wxidConfig.imageXorKey : null,
@@ -358,7 +429,7 @@ function SettingsPage() {
const applyWxidSelection = async ( const applyWxidSelection = async (
selectedWxid: string, selectedWxid: string,
options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string } options?: { preferCurrentKeys?: boolean; showToast?: boolean; toastText?: string; keysOverride?: WxidKeys }
) => { ) => {
if (!selectedWxid) return if (!selectedWxid) return
@@ -374,9 +445,9 @@ function SettingsPage() {
} }
const preferCurrentKeys = options?.preferCurrentKeys ?? false const preferCurrentKeys = options?.preferCurrentKeys ?? false
const keys = preferCurrentKeys const keys = options?.keysOverride ?? (preferCurrentKeys
? buildKeysFromState() ? buildKeysFromState()
: buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)) : buildKeysFromConfig(await configService.getWxidConfig(selectedWxid)))
setWxid(selectedWxid) setWxid(selectedWxid)
applyKeysToState(keys) applyKeysToState(keys)
@@ -444,7 +515,9 @@ function SettingsPage() {
try { try {
const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择微信数据库根目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
setDbPath(result.filePaths[0]) const selectedPath = result.filePaths[0]
setDbPath(selectedPath)
await configService.setDbPath(selectedPath)
showMessage('已选择数据库目录', true) showMessage('已选择数据库目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -454,7 +527,7 @@ function SettingsPage() {
const handleScanWxid = async ( const handleScanWxid = async (
silent = false, silent = false,
options?: { preferCurrentKeys?: boolean; showDialog?: boolean } options?: { preferCurrentKeys?: boolean; showDialog?: boolean; keysOverride?: WxidKeys }
) => { ) => {
if (!dbPath) { if (!dbPath) {
if (!silent) showMessage('请先选择数据库目录', false) if (!silent) showMessage('请先选择数据库目录', false)
@@ -468,7 +541,8 @@ function SettingsPage() {
await applyWxidSelection(wxids[0].wxid, { await applyWxidSelection(wxids[0].wxid, {
preferCurrentKeys: options?.preferCurrentKeys ?? false, preferCurrentKeys: options?.preferCurrentKeys ?? false,
showToast: !silent, showToast: !silent,
toastText: `已检测到账号:${wxids[0].wxid}` toastText: `已检测到账号:${wxids[0].wxid}`,
keysOverride: options?.keysOverride
}) })
} else if (wxids.length > 1 && allowDialog) { } else if (wxids.length > 1 && allowDialog) {
setShowWxidSelect(true) setShowWxidSelect(true)
@@ -488,7 +562,9 @@ function SettingsPage() {
try { try {
const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] }) const result = await dialog.openFile({ title: '选择缓存目录', properties: ['openDirectory'] })
if (!result.canceled && result.filePaths.length > 0) { if (!result.canceled && result.filePaths.length > 0) {
setCachePath(result.filePaths[0]) const selectedPath = result.filePaths[0]
setCachePath(selectedPath)
await configService.setCachePath(selectedPath)
showMessage('已选择缓存目录', true) showMessage('已选择缓存目录', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -554,7 +630,9 @@ function SettingsPage() {
setDecryptKey(result.key) setDecryptKey(result.key)
setDbKeyStatus('密钥获取成功') setDbKeyStatus('密钥获取成功')
showMessage('已自动获取解密密钥', true) showMessage('已自动获取解密密钥', true)
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false }) await syncCurrentKeys({ decryptKey: result.key, wxid })
const keysOverride = buildKeysFromInputs({ decryptKey: result.key })
await handleScanWxid(true, { preferCurrentKeys: true, showDialog: false, keysOverride })
} else { } else {
if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) { if (result.error?.includes('未找到微信安装路径') || result.error?.includes('启动微信失败')) {
setIsManualStartPrompt(true) setIsManualStartPrompt(true)
@@ -575,12 +653,25 @@ function SettingsPage() {
handleAutoGetDbKey() handleAutoGetDbKey()
} }
// Helper to sync current keys to wxid config // Debounce config writes to avoid excessive disk IO
const syncCurrentKeys = async () => { const scheduleConfigSave = (key: string, task: () => Promise<void> | void, delay = 300) => {
const keys = buildKeysFromState() const timers = saveTimersRef.current
if (timers[key]) {
clearTimeout(timers[key])
}
timers[key] = setTimeout(() => {
Promise.resolve(task()).catch((e) => {
console.error('保存配置失败:', e)
})
}, delay)
}
const syncCurrentKeys = async (options?: { decryptKey?: string; imageXorKey?: string; imageAesKey?: string; wxid?: string }) => {
const keys = buildKeysFromInputs(options)
await syncKeysToConfig(keys) await syncKeysToConfig(keys)
if (wxid) { const wxidToUse = options?.wxid ?? wxid
await configService.setWxidConfig(wxid, { if (wxidToUse) {
await configService.setWxidConfig(wxidToUse, {
decryptKey: keys.decryptKey, decryptKey: keys.decryptKey,
imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0, imageXorKey: typeof keys.imageXorKey === 'number' ? keys.imageXorKey : 0,
imageAesKey: keys.imageAesKey imageAesKey: keys.imageAesKey
@@ -794,6 +885,245 @@ function SettingsPage() {
</div> </div>
) )
const renderNotificationTab = () => {
const { sessions } = useChatStore.getState()
// 获取已过滤会话的信息
const getSessionInfo = (username: string) => {
const session = sessions.find(s => s.username === username)
return {
displayName: session?.displayName || username,
avatarUrl: session?.avatarUrl || ''
}
}
// 添加会话到过滤列表
const handleAddToFilterList = async (username: string) => {
if (notificationFilterList.includes(username)) return
const newList = [...notificationFilterList, username]
setNotificationFilterList(newList)
await configService.setNotificationFilterList(newList)
showMessage('已添加到过滤列表', true)
}
// 从过滤列表移除会话
const handleRemoveFromFilterList = async (username: string) => {
const newList = notificationFilterList.filter(u => u !== username)
setNotificationFilterList(newList)
await configService.setNotificationFilterList(newList)
showMessage('已从过滤列表移除', true)
}
// 过滤掉已在列表中的会话,并根据搜索关键字过滤
const availableSessions = sessions.filter(s => {
if (notificationFilterList.includes(s.username)) return false
if (filterSearchKeyword) {
const keyword = filterSearchKeyword.toLowerCase()
const displayName = (s.displayName || '').toLowerCase()
const username = s.username.toLowerCase()
return displayName.includes(keyword) || username.includes(keyword)
}
return true
})
return (
<div className="tab-content">
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="log-toggle-line">
<span className="log-status">{notificationEnabled ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="notification-enabled-toggle">
<input
id="notification-enabled-toggle"
className="switch-input"
type="checkbox"
checked={notificationEnabled}
onChange={async (e) => {
const val = e.target.checked
setNotificationEnabled(val)
await configService.setNotificationEnabled(val)
showMessage(val ? '已开启通知' : '已关闭通知', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${positionDropdownOpen ? 'open' : ''}`}
onClick={() => setPositionDropdownOpen(!positionDropdownOpen)}
>
<span className="custom-select-value">
{notificationPosition === 'top-right' ? '右上角' :
notificationPosition === 'bottom-right' ? '右下角' :
notificationPosition === 'top-left' ? '左上角' : '左下角'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${positionDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${positionDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'top-right', label: '右上角' },
{ value: 'bottom-right', label: '右下角' },
{ value: 'top-left', label: '左上角' },
{ value: 'bottom-left', label: '左下角' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationPosition === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
setNotificationPosition(val)
setPositionDropdownOpen(false)
await configService.setNotificationPosition(val)
showMessage('通知位置已更新', true)
}}
>
{option.label}
{notificationPosition === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${filterModeDropdownOpen ? 'open' : ''}`}
onClick={() => setFilterModeDropdownOpen(!filterModeDropdownOpen)}
>
<span className="custom-select-value">
{notificationFilterMode === 'all' ? '接收所有通知' :
notificationFilterMode === 'whitelist' ? '仅接收白名单' : '屏蔽黑名单'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${filterModeDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${filterModeDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'all', label: '接收所有通知' },
{ value: 'whitelist', label: '仅接收白名单' },
{ value: 'blacklist', label: '屏蔽黑名单' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${notificationFilterMode === option.value ? 'selected' : ''}`}
onClick={async () => {
const val = option.value as 'all' | 'whitelist' | 'blacklist'
setNotificationFilterMode(val)
setFilterModeDropdownOpen(false)
await configService.setNotificationFilterMode(val)
showMessage(
val === 'all' ? '已设为接收所有通知' :
val === 'whitelist' ? '已设为仅接收白名单通知' : '已设为屏蔽黑名单通知',
true
)
}}
>
{option.label}
{notificationFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
{notificationFilterMode !== 'all' && (
<div className="form-group">
<label>{notificationFilterMode === 'whitelist' ? '白名单会话' : '黑名单会话'}</label>
<span className="form-hint">
{notificationFilterMode === 'whitelist'
? '点击左侧会话添加到白名单,点击右侧会话从白名单移除'
: '点击左侧会话添加到黑名单,点击右侧会话从黑名单移除'}
</span>
<div className="notification-filter-container">
{/* 可选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span></span>
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={filterSearchKeyword}
onChange={(e) => setFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{availableSessions.length > 0 ? (
availableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => handleAddToFilterList(session.username)}
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{filterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
</div>
)}
</div>
</div>
{/* 已选会话列表 */}
<div className="filter-panel">
<div className="filter-panel-header">
<span>{notificationFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
{notificationFilterList.length > 0 && (
<span className="filter-panel-count">{notificationFilterList.length}</span>
)}
</div>
<div className="filter-panel-list">
{notificationFilterList.length > 0 ? (
notificationFilterList.map(username => {
const info = getSessionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => handleRemoveFromFilterList(username)}
>
<Avatar
src={info.avatarUrl}
name={info.displayName}
size={28}
/>
<span className="filter-item-name">{info.displayName}</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty"></div>
)}
</div>
</div>
</div>
</div>
)}
</div>
)
}
const renderDatabaseTab = () => ( const renderDatabaseTab = () => (
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
@@ -804,10 +1134,11 @@ function SettingsPage() {
type={showDecryptKey ? 'text' : 'password'} type={showDecryptKey ? 'text' : 'password'}
placeholder="例如: a1b2c3d4e5f6..." placeholder="例如: a1b2c3d4e5f6..."
value={decryptKey} value={decryptKey}
onChange={(e) => setDecryptKey(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (decryptKey && decryptKey.length === 64) { setDecryptKey(value)
await syncCurrentKeys() if (value && value.length === 64) {
scheduleConfigSave('keys', () => syncCurrentKeys({ decryptKey: value, wxid }))
// showMessage('解密密钥已保存', true) // showMessage('解密密钥已保存', true)
} }
}} }}
@@ -839,11 +1170,14 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: C:\Users\xxx\Documents\xwechat_files" placeholder="例如: C:\Users\xxx\Documents\xwechat_files"
value={dbPath} value={dbPath}
onChange={(e) => setDbPath(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (dbPath) { setDbPath(value)
await configService.setDbPath(dbPath) scheduleConfigSave('dbPath', async () => {
if (value) {
await configService.setDbPath(value)
} }
})
}} }}
/> />
<div className="btn-row"> <div className="btn-row">
@@ -862,12 +1196,43 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: wxid_xxxxxx" placeholder="例如: wxid_xxxxxx"
value={wxid} value={wxid}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
if (wxid) { const previousWxid = wxid
await configService.setMyWxid(wxid) setWxid(value)
await syncCurrentKeys() // Sync keys to the new wxid entry scheduleConfigSave('wxid', async () => {
if (previousWxid && previousWxid !== value) {
const currentKeys = buildKeysFromState()
await configService.setWxidConfig(previousWxid, {
decryptKey: currentKeys.decryptKey,
imageXorKey: typeof currentKeys.imageXorKey === 'number' ? currentKeys.imageXorKey : 0,
imageAesKey: currentKeys.imageAesKey
})
} }
if (value) {
await configService.setMyWxid(value)
await syncCurrentKeys({ wxid: value }) // Sync keys to the new wxid entry
}
if (value && previousWxid !== value) {
if (isDbConnected) {
try {
await window.electronAPI.chat.close()
const result = await window.electronAPI.chat.connect()
setDbConnected(result.success, dbPath || undefined)
if (!result.success && result.error) {
showMessage(result.error, false)
}
} catch (e: any) {
showMessage(`切换账号后重新连接失败: ${e}`, false)
setDbConnected(false)
}
}
clearAnalyticsStoreCache()
resetChatStore()
window.dispatchEvent(new CustomEvent('wxid-changed', { detail: { wxid: value } }))
}
})
}} }}
/> />
</div> </div>
@@ -881,8 +1246,14 @@ function SettingsPage() {
type="text" type="text"
placeholder="例如: 0xA4" placeholder="例如: 0xA4"
value={imageXorKey} value={imageXorKey}
onChange={(e) => setImageXorKey(e.target.value)} onChange={(e) => {
onBlur={syncCurrentKeys} const value = e.target.value
setImageXorKey(value)
const parsed = parseImageXorKey(value)
if (value === '' || parsed !== null) {
scheduleConfigSave('keys', () => syncCurrentKeys({ imageXorKey: value, wxid }))
}
}}
/> />
</div> </div>
@@ -893,8 +1264,11 @@ function SettingsPage() {
type="text" type="text"
placeholder="16 位 AES 密钥" placeholder="16 位 AES 密钥"
value={imageAesKey} value={imageAesKey}
onChange={(e) => setImageAesKey(e.target.value)} onChange={(e) => {
onBlur={syncCurrentKeys} const value = e.target.value
setImageAesKey(value)
scheduleConfigSave('keys', () => syncCurrentKeys({ imageAesKey: value, wxid }))
}}
/> />
<button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}> <button className="btn btn-secondary btn-sm" onClick={handleAutoGetImageKey} disabled={isFetchingImageKey}>
<Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'} <Plug size={14} /> {isFetchingImageKey ? '获取中...' : '自动获取图片密钥'}
@@ -1009,8 +1383,11 @@ function SettingsPage() {
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={whisperModelDir} value={whisperModelDir}
onChange={(e) => setWhisperModelDir(e.target.value)} onChange={(e) => {
onBlur={() => configService.setWhisperModelDir(whisperModelDir)} const value = e.target.value
setWhisperModelDir(value)
scheduleConfigSave('whisperModelDir', () => configService.setWhisperModelDir(value))
}}
/> />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button> <button className="btn btn-secondary" onClick={handleSelectWhisperModelDir}><FolderOpen size={16} /> </button>
@@ -1064,6 +1441,15 @@ function SettingsPage() {
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' } { value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] ]
const exportConcurrencyOptions = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
{ value: 6, label: '6' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => { const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value return options.find((option) => option.value === value)?.label ?? value
} }
@@ -1073,6 +1459,7 @@ function SettingsPage() {
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat) const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange) const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue) const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return ( return (
<div className="tab-content"> <div className="tab-content">
@@ -1087,6 +1474,7 @@ function SettingsPage() {
setShowExportFormatSelect(!showExportFormatSelect) setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false) setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportFormatLabel}</span> <span className="select-value">{exportFormatLabel}</span>
@@ -1126,6 +1514,7 @@ function SettingsPage() {
setShowExportDateRangeSelect(!showExportDateRangeSelect) setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false) setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportDateRangeLabel}</span> <span className="select-value">{exportDateRangeLabel}</span>
@@ -1210,6 +1599,7 @@ function SettingsPage() {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect) setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false) setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false) setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}} }}
> >
<span className="select-value">{exportExcelColumnsLabel}</span> <span className="select-value">{exportExcelColumnsLabel}</span>
@@ -1239,6 +1629,45 @@ function SettingsPage() {
</div> </div>
</div> </div>
<div className="form-group">
<label></label>
<span className="form-hint">1~6</span>
<div className="select-field" ref={exportConcurrencyDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
onClick={() => {
setShowExportConcurrencySelect(!showExportConcurrencySelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportConcurrencyLabel}</span>
<ChevronDown size={16} />
</button>
{showExportConcurrencySelect && (
<div className="select-dropdown">
{exportConcurrencyOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultConcurrency(option.value)
await configService.setExportDefaultConcurrency(option.value)
showMessage(`已将导出并发数设为 ${option.value}`, true)
setShowExportConcurrencySelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
</div> </div>
) )
} }
@@ -1252,14 +1681,23 @@ function SettingsPage() {
type="text" type="text"
placeholder="留空使用默认目录" placeholder="留空使用默认目录"
value={cachePath} value={cachePath}
onChange={(e) => setCachePath(e.target.value)} onChange={(e) => {
onBlur={async () => { const value = e.target.value
await configService.setCachePath(cachePath) setCachePath(value)
scheduleConfigSave('cachePath', () => configService.setCachePath(value))
}} }}
/> />
<div className="btn-row"> <div className="btn-row">
<button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button> <button className="btn btn-secondary" onClick={handleSelectCachePath}><FolderOpen size={16} /> </button>
<button className="btn btn-secondary" onClick={() => setCachePath('')}><RotateCcw size={16} /> </button> <button
className="btn btn-secondary"
onClick={async () => {
setCachePath('')
await configService.setCachePath('')
}}
>
<RotateCcw size={16} />
</button>
</div> </div>
</div> </div>
@@ -1518,6 +1956,7 @@ function SettingsPage() {
<div className="settings-body"> <div className="settings-body">
{activeTab === 'appearance' && renderAppearanceTab()} {activeTab === 'appearance' && renderAppearanceTab()}
{activeTab === 'notification' && renderNotificationTab()}
{activeTab === 'database' && renderDatabaseTab()} {activeTab === 'database' && renderDatabaseTab()}
{activeTab === 'whisper' && renderWhisperTab()} {activeTab === 'whisper' && renderWhisperTab()}
{activeTab === 'export' && renderExportTab()} {activeTab === 'export' && renderExportTab()}

View File

@@ -547,10 +547,41 @@
.sns-content-wrapper { .sns-content-wrapper {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.sns-notice-banner {
margin: 16px 24px 0 24px;
padding: 10px 16px;
background: rgba(var(--accent-color-rgb), 0.08);
border-radius: 10px;
border: 1px solid rgba(var(--accent-color-rgb), 0.2);
display: flex;
align-items: center;
gap: 10px;
color: var(--accent-color);
font-size: 13px;
font-weight: 500;
animation: banner-slide-down 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
svg {
flex-shrink: 0;
}
}
@keyframes banner-slide-down {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sns-content { .sns-content {
flex: 1; flex: 1;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, useRef, useCallback, useMemo } from 'react' import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight } from 'lucide-react' import { RefreshCw, Heart, Search, Calendar, User, X, Filter, Play, ImageIcon, Zap, Download, ChevronRight, AlertTriangle } from 'lucide-react'
import { Avatar } from '../components/Avatar' import { Avatar } from '../components/Avatar'
import { ImagePreview } from '../components/ImagePreview' import { ImagePreview } from '../components/ImagePreview'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
@@ -149,7 +149,7 @@ export default function SnsPage() {
const currentPosts = postsRef.current const currentPosts = postsRef.current
if (currentPosts.length > 0) { if (currentPosts.length > 0) {
const topTs = currentPosts[0].createTime const topTs = currentPosts[0].createTime
console.log('[SnsPage] Fetching newer posts starts from:', topTs + 1);
const result = await window.electronAPI.sns.getTimeline( const result = await window.electronAPI.sns.getTimeline(
limit, limit,
@@ -165,8 +165,8 @@ export default function SnsPage() {
scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight; scrollAdjustmentRef.current = postsContainerRef.current.scrollHeight;
} }
const existingIds = new Set(currentPosts.map(p => p.id)); const existingIds = new Set(currentPosts.map((p: SnsPost) => p.id));
const uniqueNewer = result.timeline.filter(p => !existingIds.has(p.id)); const uniqueNewer = result.timeline.filter((p: SnsPost) => !existingIds.has(p.id));
if (uniqueNewer.length > 0) { if (uniqueNewer.length > 0) {
setPosts(prev => [...uniqueNewer, ...prev]); setPosts(prev => [...uniqueNewer, ...prev]);
@@ -253,7 +253,7 @@ export default function SnsPage() {
})) }))
setContacts(initialContacts) setContacts(initialContacts)
const usernames = initialContacts.map(c => c.username) const usernames = initialContacts.map((c: { username: string }) => c.username)
const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames) const enriched = await window.electronAPI.chat.enrichSessionsContactInfo(usernames)
if (enriched.success && enriched.contacts) { if (enriched.success && enriched.contacts) {
setContacts(prev => prev.map(c => { setContacts(prev => prev.map(c => {
@@ -281,10 +281,10 @@ export default function SnsPage() {
const checkSchema = async () => { const checkSchema = async () => {
try { try {
const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)"); const schema = await window.electronAPI.chat.execQuery('sns', null, "PRAGMA table_info(SnsTimeLine)");
console.log('[SnsPage] SnsTimeLine Schema:', schema);
if (schema.success && schema.rows) { if (schema.success && schema.rows) {
const columns = schema.rows.map((r: any) => r.name); const columns = schema.rows.map((r: any) => r.name);
console.log('[SnsPage] Available columns:', columns);
} }
} catch (e) { } catch (e) {
console.error('[SnsPage] Failed to check schema:', e); console.error('[SnsPage] Failed to check schema:', e);
@@ -335,7 +335,7 @@ export default function SnsPage() {
// deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端 // deltaY < 0 表示向上滚scrollTop === 0 表示已经在最顶端
if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) { if (e.deltaY < -20 && container.scrollTop <= 0 && hasNewer && !loading && !loadingNewer) {
console.log('[SnsPage] Wheel-up detected at top, loading newer posts...');
loadPosts({ direction: 'newer' }) loadPosts({ direction: 'newer' })
} }
} }
@@ -412,6 +412,10 @@ export default function SnsPage() {
</div> </div>
<div className="sns-content-wrapper"> <div className="sns-content-wrapper">
<div className="sns-notice-banner">
<AlertTriangle size={16} />
<span></span>
</div>
<div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}> <div className="sns-content custom-scrollbar" onScroll={handleScroll} onWheel={handleWheel} ref={postsContainerRef}>
<div className="posts-list"> <div className="posts-list">
{loadingNewer && ( {loadingNewer && (

View File

@@ -435,6 +435,58 @@
} }
} }
.wxid-select {
position: relative;
}
.wxid-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
max-height: 220px;
overflow: auto;
z-index: 20;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.08);
}
.wxid-option {
width: 100%;
border: none;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
color: var(--text-primary);
font-size: 13px;
&:hover {
background: var(--bg-hover);
}
&.active {
background: var(--primary-light);
}
}
.wxid-name {
font-weight: 600;
}
.wxid-time {
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
.field-with-toggle { .field-with-toggle {
position: relative; position: relative;
} }

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import { dialog } from '../services/ipc' import { dialog } from '../services/ipc'
@@ -35,6 +35,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
const [cachePath, setCachePath] = useState('') const [cachePath, setCachePath] = useState('')
const [wxid, setWxid] = useState('') const [wxid, setWxid] = useState('')
const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([]) const [wxidOptions, setWxidOptions] = useState<Array<{ wxid: string; modifiedTime: number }>>([])
const [showWxidSelect, setShowWxidSelect] = useState(false)
const wxidSelectRef = useRef<HTMLDivElement>(null)
const [error, setError] = useState('') const [error, setError] = useState('')
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [isDetectingPath, setIsDetectingPath] = useState(false) const [isDetectingPath, setIsDetectingPath] = useState(false)
@@ -106,10 +108,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
useEffect(() => { useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => { const removeDb = window.electronAPI.key.onDbKeyStatus((payload: { message: string; level: number }) => {
setDbKeyStatus(payload.message) setDbKeyStatus(payload.message)
}) })
const removeImage = window.electronAPI.key.onImageKeyStatus((payload) => { const removeImage = window.electronAPI.key.onImageKeyStatus((payload: { message: string }) => {
setImageKeyStatus(payload.message) setImageKeyStatus(payload.message)
}) })
return () => { return () => {
@@ -127,8 +129,22 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
useEffect(() => { useEffect(() => {
setWxidOptions([]) setWxidOptions([])
setWxid('') setWxid('')
setShowWxidSelect(false)
}, [dbPath]) }, [dbPath])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!showWxidSelect) return
const target = event.target as Node
if (wxidSelectRef.current && !wxidSelectRef.current.contains(target)) {
setShowWxidSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showWxidSelect])
const currentStep = steps[stepIndex] const currentStep = steps[stepIndex]
const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}` const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}`
const showWindowControls = standalone const showWindowControls = standalone
@@ -217,6 +233,28 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
} }
const handleScanWxidCandidates = async () => {
if (!dbPath) {
setError('请先选择数据库目录')
return
}
if (isScanningWxid) return
setIsScanningWxid(true)
setError('')
try {
const wxids = await window.electronAPI.dbPath.scanWxidCandidates(dbPath)
setWxidOptions(wxids)
setShowWxidSelect(true)
if (!wxids.length) {
setError('未检测到可用的账号目录,请检查路径')
}
} catch (e) {
setError(`扫描失败: ${e}`)
} finally {
setIsScanningWxid(false)
}
}
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
if (isFetchingDbKey) return if (isFetchingDbKey) return
setIsFetchingDbKey(true) setIsFetchingDbKey(true)
@@ -556,14 +594,35 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'key' && ( {currentStep.id === 'key' && (
<div className="form-group"> <div className="form-group">
<label className="field-label"> (Wxid)</label> <label className="field-label"> (Wxid)</label>
<div className="wxid-select" ref={wxidSelectRef}>
<input <input
type="text" type="text"
className="field-input" className="field-input"
placeholder="等待获取..." placeholder="点击选择..."
value={wxid} value={wxid}
readOnly readOnly
onClick={handleScanWxidCandidates}
onChange={(e) => setWxid(e.target.value)} onChange={(e) => setWxid(e.target.value)}
/> />
{showWxidSelect && wxidOptions.length > 0 && (
<div className="wxid-dropdown">
{wxidOptions.map((opt) => (
<button
key={opt.wxid}
type="button"
className={`wxid-option ${opt.wxid === wxid ? 'active' : ''}`}
onClick={() => {
setWxid(opt.wxid)
setShowWxidSelect(false)
}}
>
<span className="wxid-name">{opt.wxid}</span>
<span className="wxid-time">{formatModifiedTime(opt.modifiedTime)}</span>
</button>
))}
</div>
)}
</div>
<label className="field-label mt-4"></label> <label className="field-label mt-4"></label>
<div className="field-with-toggle"> <div className="field-with-toggle">
@@ -733,4 +792,3 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
} }
export default WelcomePage export default WelcomePage

View File

@@ -35,7 +35,16 @@ export const CONFIG_KEYS = {
// 安全 // 安全
AUTH_ENABLED: 'authEnabled', AUTH_ENABLED: 'authEnabled',
AUTH_PASSWORD: 'authPassword', AUTH_PASSWORD: 'authPassword',
AUTH_USE_HELLO: 'authUseHello' AUTH_USE_HELLO: 'authUseHello',
// 更新
IGNORED_UPDATE_VERSION: 'ignoredUpdateVersion',
// 通知
NOTIFICATION_ENABLED: 'notificationEnabled',
NOTIFICATION_POSITION: 'notificationPosition',
NOTIFICATION_FILTER_MODE: 'notificationFilterMode',
NOTIFICATION_FILTER_LIST: 'notificationFilterList'
} as const } as const
export interface WxidConfig { export interface WxidConfig {
@@ -399,3 +408,60 @@ export async function getAuthUseHello(): Promise<boolean> {
export async function setAuthUseHello(useHello: boolean): Promise<void> { export async function setAuthUseHello(useHello: boolean): Promise<void> {
await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello) await config.set(CONFIG_KEYS.AUTH_USE_HELLO, useHello)
} }
// === 更新相关 ===
// 获取被忽略的更新版本
export async function getIgnoredUpdateVersion(): Promise<string | null> {
const value = await config.get(CONFIG_KEYS.IGNORED_UPDATE_VERSION)
return (value as string) || null
}
// 设置被忽略的更新版本
export async function setIgnoredUpdateVersion(version: string): Promise<void> {
await config.set(CONFIG_KEYS.IGNORED_UPDATE_VERSION, version)
}
// 获取通知开关
export async function getNotificationEnabled(): Promise<boolean> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_ENABLED)
return value !== false // 默认为 true
}
// 设置通知开关
export async function setNotificationEnabled(enabled: boolean): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_ENABLED, enabled)
}
// 获取通知位置
export async function getNotificationPosition(): Promise<'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_POSITION)
return (value as any) || 'top-right'
}
// 设置通知位置
export async function setNotificationPosition(position: string): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_POSITION, position)
}
// 获取通知过滤模式
export async function getNotificationFilterMode(): Promise<'all' | 'whitelist' | 'blacklist'> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_MODE)
return (value as any) || 'all'
}
// 设置通知过滤模式
export async function setNotificationFilterMode(mode: 'all' | 'whitelist' | 'blacklist'): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_MODE, mode)
}
// 获取通知过滤列表
export async function getNotificationFilterList(): Promise<string[]> {
const value = await config.get(CONFIG_KEYS.NOTIFICATION_FILTER_LIST)
return Array.isArray(value) ? value : []
}
// 设置通知过滤列表
export async function setNotificationFilterList(list: string[]): Promise<void> {
await config.set(CONFIG_KEYS.NOTIFICATION_FILTER_LIST, list)
}

View File

@@ -33,6 +33,10 @@ export interface AppState {
setShowUpdateDialog: (show: boolean) => void setShowUpdateDialog: (show: boolean) => void
setUpdateError: (error: string | null) => void setUpdateError: (error: string | null) => void
// 锁定状态
isLocked: boolean
setLocked: (locked: boolean) => void
reset: () => void reset: () => void
} }
@@ -42,6 +46,7 @@ export const useAppStore = create<AppState>((set) => ({
myWxid: null, myWxid: null,
isLoading: false, isLoading: false,
loadingText: '', loadingText: '',
isLocked: false,
// 更新状态初始化 // 更新状态初始化
updateInfo: null, updateInfo: null,
@@ -62,6 +67,8 @@ export const useAppStore = create<AppState>((set) => ({
loadingText: text ?? '' loadingText: text ?? ''
}), }),
setLocked: (locked) => set({ isLocked: locked }),
setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }), setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }),
setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }), setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }),
setDownloadProgress: (progress) => set({ downloadProgress: progress }), setDownloadProgress: (progress) => set({ downloadProgress: progress }),
@@ -74,6 +81,7 @@ export const useAppStore = create<AppState>((set) => ({
myWxid: null, myWxid: null,
isLoading: false, isLoading: false,
loadingText: '', loadingText: '',
isLocked: false,
updateInfo: null, updateInfo: null,
isDownloading: false, isDownloading: false,
downloadProgress: { percent: 0 }, downloadProgress: { percent: 0 },

View File

@@ -80,11 +80,23 @@ export const useChatStore = create<ChatState>((set, get) => ({
setMessages: (messages) => set({ messages }), setMessages: (messages) => set({ messages }),
appendMessages: (newMessages, prepend = false) => set((state) => ({ appendMessages: (newMessages, prepend = false) => set((state) => {
// 强制去重逻辑
const getMsgKey = (m: Message) => {
if (m.localId && m.localId > 0) return `l:${m.localId}`
return `t:${m.createTime}:${m.sortSeq || 0}:${m.serverId || 0}`
}
const existingKeys = new Set(state.messages.map(getMsgKey))
const filtered = newMessages.filter(m => !existingKeys.has(getMsgKey(m)))
if (filtered.length === 0) return state
return {
messages: prepend messages: prepend
? [...newMessages, ...state.messages] ? [...filtered, ...state.messages]
: [...state.messages, ...newMessages] : [...state.messages, ...filtered]
})), }
}),
setLoadingMessages: (loading) => set({ isLoadingMessages: loading }), setLoadingMessages: (loading) => set({ isLoadingMessages: loading }),
setLoadingMore: (loading) => set({ isLoadingMore: loading }), setLoadingMore: (loading) => set({ isLoadingMore: loading }),

View File

@@ -11,6 +11,7 @@ export interface ElectronAPI {
setTitleBarOverlay: (options: { symbolColor: string }) => void setTitleBarOverlay: (options: { symbolColor: string }) => void
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void> openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void> resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
openImageViewerWindow: (imagePath: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
} }
config: { config: {
@@ -32,6 +33,7 @@ export interface ElectronAPI {
getVersion: () => Promise<string> getVersion: () => Promise<string>
checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }> checkForUpdates: () => Promise<{ hasUpdate: boolean; version?: string; releaseNotes?: string }>
downloadAndInstall: () => Promise<void> downloadAndInstall: () => Promise<void>
ignoreUpdate: (version: string) => Promise<{ success: boolean }>
onDownloadProgress: (callback: (progress: number) => void) => () => void onDownloadProgress: (callback: (progress: number) => void) => () => void
onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void onUpdateAvailable: (callback: (info: { version: string; releaseNotes: string }) => void) => () => void
} }
@@ -42,6 +44,7 @@ export interface ElectronAPI {
dbPath: { dbPath: {
autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }> autoDetect: () => Promise<{ success: boolean; path?: string; error?: string }>
scanWxids: (rootPath: string) => Promise<WxidInfo[]> scanWxids: (rootPath: string) => Promise<WxidInfo[]>
scanWxidCandidates: (rootPath: string) => Promise<WxidInfo[]>
getDefault: () => Promise<string> getDefault: () => Promise<string>
} }
wcdb: { wcdb: {
@@ -75,6 +78,11 @@ export interface ElectronAPI {
messages?: Message[] messages?: Message[]
error?: string error?: string
}> }>
getNewMessages: (sessionId: string, minTime: number, limit?: number) => Promise<{
success: boolean
messages?: Message[]
error?: string
}>
getContact: (username: string) => Promise<Contact | null> getContact: (username: string) => Promise<Contact | null>
getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null> getContactAvatar: (username: string) => Promise<{ avatarUrl?: string; displayName?: string } | null>
getContacts: () => Promise<{ getContacts: () => Promise<{
@@ -108,6 +116,7 @@ export interface ElectronAPI {
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }> execQuery: (kind: string, path: string | null, sql: string) => Promise<{ success: boolean; rows?: any[]; error?: string }>
getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }> getMessage: (sessionId: string, localId: number) => Promise<{ success: boolean; message?: Message; error?: string }>
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => () => void
} }
image: { image: {
@@ -174,6 +183,26 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
getExcludedUsernames: () => Promise<{
success: boolean
data?: string[]
error?: string
}>
setExcludedUsernames: (usernames: string[]) => Promise<{
success: boolean
data?: string[]
error?: string
}>
getExcludeCandidates: () => Promise<{
success: boolean
data?: Array<{
username: string
displayName: string
avatarUrl?: string
wechatId?: string
}>
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
} }
cache: { cache: {
@@ -198,6 +227,10 @@ export interface ElectronAPI {
username: string username: string
displayName: string displayName: string
avatarUrl?: string avatarUrl?: string
nickname?: string
alias?: string
remark?: string
groupNickname?: string
}> }>
error?: string error?: string
}> }>
@@ -232,6 +265,11 @@ export interface ElectronAPI {
} }
error?: string error?: string
}> }>
exportGroupMembers: (chatroomId: string, outputPath: string) => Promise<{
success: boolean
count?: number
error?: string
}>
} }
annualReport: { annualReport: {
getAvailableYears: () => Promise<{ getAvailableYears: () => Promise<{
@@ -311,6 +349,57 @@ export interface ElectronAPI {
}> }>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
} }
dualReport: {
generateReport: (payload: { friendUsername: string; year: number }) => Promise<{
success: boolean
data?: {
year: number
selfName: string
friendUsername: string
friendName: string
firstChat: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
senderUsername?: string
} | null
firstChatMessages?: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
yearFirstChat?: {
createTime: number
createTimeStr: string
content: string
isSentByMe: boolean
friendName: string
firstThreeMessages: Array<{
content: string
isSentByMe: boolean
createTime: number
createTimeStr: string
}>
} | null
stats: {
totalMessages: number
totalWords: number
imageCount: number
voiceCount: number
emojiCount: number
myTopEmojiMd5?: string
friendTopEmojiMd5?: string
myTopEmojiUrl?: string
friendTopEmojiUrl?: string
}
topPhrases: Array<{ phrase: string; count: number }>
}
error?: string
}>
onProgress: (callback: (payload: { status: string; progress: number }) => void) => () => void
}
export: { export: {
exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{ exportSessions: (sessionIds: string[], outputDir: string, options: ExportOptions) => Promise<{
success: boolean success: boolean

View File

@@ -9,6 +9,9 @@ export interface ChatSession {
lastMsgType: number lastMsgType: number
displayName?: string displayName?: string
avatarUrl?: string avatarUrl?: string
lastMsgSender?: string
lastSenderDisplayName?: string
selfWxid?: string // Helper field to avoid extra API calls
} }
// 联系人 // 联系人

13
src/vite-env.d.ts vendored
View File

@@ -1 +1,14 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface Window {
electronAPI: {
// ... other methods ...
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
}
// For brevity, using 'any' for other parts or properly importing types if available.
// In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts
// or import a shared type definition.
[key: string]: any
}
}

View File

@@ -57,6 +57,24 @@ export default defineConfig({
} }
} }
}, },
{
entry: 'electron/dualReportWorker.ts',
vite: {
build: {
outDir: 'dist-electron',
rollupOptions: {
external: [
'koffi',
'fsevents'
],
output: {
entryFileNames: 'dualReportWorker.js',
inlineDynamicImports: true
}
}
}
}
},
{ {
entry: 'electron/imageSearchWorker.ts', entry: 'electron/imageSearchWorker.ts',
vite: { vite: {