Compare commits

..

56 Commits

Author SHA1 Message Date
xuncha
6968936c8f Merge pull request #312 from hicccc77/dev
Dev
2026-02-25 19:52:23 +08:00
xuncha
a571278145 Merge pull request #311 from xunchahaha:dev
修复引用消息错误的问题
2026-02-25 19:23:18 +08:00
xuncha
e4e25394e2 修复引用消息错误的问题 2026-02-25 19:22:53 +08:00
xuncha
fe47d7b9e3 Merge pull request #310 from hicccc77/dev
Dev
2026-02-25 18:03:32 +08:00
xuncha
4bb5bc6e32 Merge pull request #309 from xunchahaha:dev
Dev
2026-02-25 18:03:07 +08:00
xuncha
49d951e96a 1 2026-02-25 18:01:27 +08:00
xuncha
9585a02959 修复透明卡片问题 2026-02-25 17:59:42 +08:00
xuncha
a51fa5e4a2 修复 2026-02-25 17:26:45 +08:00
xuncha
bc0671440c 更新消息类型适配 2026-02-25 17:07:47 +08:00
xuncha
1a07c3970f 简单优化图片解密 2026-02-25 14:54:08 +08:00
xuncha
83c07b27f9 图片批量解密 图片解密优化 2026-02-25 14:23:22 +08:00
xuncha
fbcf7d2fc3 实况播放更加丝滑 2026-02-25 13:54:06 +08:00
cc
b547ac1aed 重要安全更新 2026-02-25 13:25:25 +08:00
cc
411f8a8d61 修复朋友圈封面信息被错误解析的问题;解决了一些安全问题 2026-02-25 12:12:08 +08:00
cc
b3741a5cf4 Merge pull request #299 from hicccc77/main
main
2026-02-23 10:32:18 +08:00
cc
b1cf524612 Merge pull request #298 from hicccc77/dev
聊天页面支持实况解析;朋友圈页面优化
2026-02-23 10:31:37 +08:00
cc
364c920fff Merge pull request #297 from Leoluis0705/fix-issues-clean
fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能
2026-02-23 10:30:55 +08:00
你的名字
e89ccee5f4 refactor: 响应Codex代码评审建议 2026-02-23 10:28:51 +08:00
你的名字
6a86e69cd4 fix: 修复聊天记录加载问题 2026-02-23 10:28:45 +08:00
你的名字
ab2c086e93 fix: 修复更新弹窗无响应、内存泄漏、SQL注入、文件句柄泄漏及并发安全问题;优化导出功能 2026-02-23 09:55:33 +08:00
cc
b9c65e634c 聊天页面支持实况解析;朋友圈页面优化 2026-02-22 21:39:11 +08:00
cc
b7852a8c07 Merge pull request #293 from hicccc77/dev
Dev
2026-02-22 15:26:46 +08:00
cc
4b9d94eb62 修复实时更新偶发失效的问题;删除AI对话有关组件与依赖 2026-02-22 15:26:13 +08:00
cc
70481fd468 Merge branch 'dev' of https://github.com/hicccc77/WeFlow into dev 2026-02-22 14:26:44 +08:00
cc
52c67f4d23 修复开启应用锁时更新公告弹窗无法关闭的bug #291;修复朋友圈时间排序错乱 #290;支持日期选择器快速跳转年月;朋友圈页面性能优化 2026-02-22 14:26:41 +08:00
xuncha
d3618f3065 Merge pull request #292 from hicccc77/xunchahaha-patch-1
Xunchahaha patch 1
2026-02-22 13:02:04 +08:00
xuncha
29472beee8 Update README.md 2026-02-22 13:01:41 +08:00
cc
acaac507b1 支持系统深浅自适应同步 2026-02-21 23:23:40 +08:00
cc
f25c23b2b3 Merge pull request #287 from hicccc77/dev
Dev
2026-02-21 23:07:10 +08:00
cc
5ab0466a87 联系人页面优化算法,同时支持获取曾经的好友;支持通过联系人页面打开聊天会话;朋友圈页面优化;支持检测并标记部分已删除的朋友圈 2026-02-21 23:06:41 +08:00
cc
d49c44f3be Merge pull request #286 from hicccc77/main
Main
2026-02-21 12:56:32 +08:00
cc
4577b4e955 修复了一些问题,并引入了新的问题 2026-02-21 12:55:44 +08:00
cc
dafde2eaba Merge pull request #283 from hicccc77/dev
Dev
2026-02-20 21:57:07 +08:00
The Shit Code Here
db4fab9130 修复HTML导出图片文件名冲突 (#282)
Co-authored-by: 0xshitcode <0xshitcode@users.noreply.github.com>
2026-02-20 21:55:31 +08:00
cc
9aee578707 支持朋友圈导出 2026-02-20 21:53:35 +08:00
cc
6d74eb65ae 更新朋友圈样式 2026-02-20 21:53:35 +08:00
cc
6e8ae3a12b 支持朋友圈导出 2026-02-20 21:50:02 +08:00
cc
a4be7f9005 更新朋友圈样式 2026-02-20 11:28:25 +08:00
xuncha
587ee630d7 Merge pull request #281 from hicccc77/dev
Dev
2026-02-19 18:44:39 +08:00
xuncha
6952a5f680 Merge pull request #280 from xunchahaha:main
Main
2026-02-19 18:43:55 +08:00
xuncha
b263ecd45c 修复会话太多的堵塞 2026-02-19 18:43:16 +08:00
xuncha
74fc0e4e88 Merge pull request #279 from hicccc77/dev
Dev
2026-02-19 18:07:34 +08:00
xuncha
a873366342 Merge pull request #278 from xunchahaha/dev
Dev
2026-02-19 18:07:09 +08:00
xuncha
c4dc266f93 排除好友防呆设计 2026-02-19 18:05:37 +08:00
xuncha
96ff783bbd html导出卡片链接优化 2026-02-19 17:55:01 +08:00
xuncha
804a65f52b 单个好友导出ui优化 2026-02-19 17:54:55 +08:00
xuncha
e88c859f4f 成员消息导出单拎出来 2026-02-19 17:40:41 +08:00
xuncha
c1a393eaf6 修改中文注释 2026-02-19 17:28:12 +08:00
xuncha
15e08dc529 修复朋友圈视频也走卡片消息解析 2026-02-19 17:12:28 +08:00
xuncha
e55bcaf7eb Merge branch 'dev' of https://github.com/xunchahaha/WeFlow into dev 2026-02-19 17:05:47 +08:00
xuncha
4e64c6ad6e api相关优化 2026-02-19 17:05:43 +08:00
xuncha
5a15e1a1d6 Merge branch 'hicccc77:dev' into dev 2026-02-19 16:54:43 +08:00
xuncha
ba07d47496 朋友圈优化卡片消息类 2026-02-19 16:51:32 +08:00
xuncha
25325e80ee 通讯录可勾选部分好友导出 2026-02-19 16:49:46 +08:00
xuncha
89783b4d45 群聊单个成员消息导出 2026-02-19 16:49:00 +08:00
xuncha
d5f0094025 优化转账类消息导出 2026-02-19 16:47:50 +08:00
66 changed files with 9785 additions and 6029 deletions

5
.gitignore vendored
View File

@@ -60,4 +60,7 @@ wcdb/
概述.md 概述.md
chatlab-format.md chatlab-format.md
*.bak *.bak
AGENTS.md AGENTS.md
.claude/
.agents/
resources/wx_send

View File

@@ -19,6 +19,7 @@ WeFlow 是一个**完全本地**的微信**实时**聊天记录查看、分析
</a> </a>
<a href="https://github.com/hicccc77/WeFlow/issues"> <a href="https://github.com/hicccc77/WeFlow/issues">
<img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues"> <img src="https://img.shields.io/github/issues/hicccc77/WeFlow?style=flat-square" alt="Issues">
<img src="https://gh-down-badges.linkof.link/hicccc77/WeFlow/" alt="Downloads" />
</a> </a>
<a href="https://t.me/weflow_cc"> <a href="https://t.me/weflow_cc">
<img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram"> <img src="https://img.shields.io/badge/Telegram%20频道-0088cc?style=flat-square&logo=telegram&logoColor=0088cc&labelColor=white" alt="Telegram">

View File

@@ -50,12 +50,20 @@ GET /api/v1/messages
| 参数名 | 类型 | 必填 | 说明 | | 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------| |--------|------|------|------|
| `talker` | string | ✅ | 会话 IDwxid 或群 ID | | `talker` | string | ✅ | 会话 IDwxid 或群 ID |
| `limit` | number | ❌ | 返回数量限制,默认 100 | | `limit` | number | ❌ | 返回数量限制,默认 100,范围 `1~10000` |
| `offset` | number | ❌ | 偏移量,用于分页,默认 0 | | `offset` | number | ❌ | 偏移量,用于分页,默认 0 |
| `start` | string | ❌ | 开始时间,格式 YYYYMMDD | | `start` | string | ❌ | 开始时间,格式 YYYYMMDD |
| `end` | string | ❌ | 结束时间,格式 YYYYMMDD | | `end` | string | ❌ | 结束时间,格式 YYYYMMDD |
| `keyword` | string | ❌ | 关键词过滤(基于消息显示文本) |
| `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 | | `chatlab` | string | ❌ | 设为 `1` 则输出 ChatLab 格式 |
| `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` | | `format` | string | ❌ | 输出格式:`json`(默认)或 `chatlab` |
| `media` | string | ❌ | 设为 `1` 时导出媒体并返回媒体路径(兼容别名 `meiti``0` 时媒体返回占位符 |
| `image` | string | ❌ | 在 `media=1` 时控制图片导出,`1/0`(兼容别名 `tupian` |
| `voice` | string | ❌ | 在 `media=1` 时控制语音导出,`1/0`(兼容别名 `vioce` |
| `video` | string | ❌ | 在 `media=1` 时控制视频导出,`1/0` |
| `emoji` | string | ❌ | 在 `media=1` 时控制表情导出,`1/0` |
默认媒体导出目录:`%USERPROFILE%\\Documents\\WeFlow\\api-media`
**示例请求** **示例请求**
@@ -68,6 +76,12 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&chatlab=1
# 带时间范围查询 # 带时间范围查询
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100 GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=20260205&limit=100
# 开启媒体导出(只导出图片和语音)
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&media=1&image=1&voice=1&video=0&emoji=0
# 关键词过滤
GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&limit=50
``` ```
**响应(原始格式)** **响应(原始格式)**
@@ -77,15 +91,21 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
"talker": "wxid_xxx", "talker": "wxid_xxx",
"count": 50, "count": 50,
"hasMore": true, "hasMore": true,
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
},
"messages": [ "messages": [
{ {
"localId": 123, "localId": 123,
"talker": "wxid_xxx", "localType": 3,
"type": 1, "content": "[图片]",
"content": "消息内容",
"createTime": 1738713600000, "createTime": 1738713600000,
"isSelf": false, "senderUsername": "wxid_sender",
"sender": "wxid_sender" "mediaType": "image",
"mediaFileName": "image_123.jpg",
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
} }
] ]
} }
@@ -119,9 +139,15 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&start=20260101&end=202
"accountName": "用户名", "accountName": "用户名",
"timestamp": 1738713600000, "timestamp": 1738713600000,
"type": 0, "type": 0,
"content": "消息内容" "content": "消息内容",
"mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg"
} }
] ],
"media": {
"enabled": true,
"exportPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media",
"count": 12
}
} }
``` ```

View File

@@ -21,7 +21,7 @@ import { videoService } from './services/videoService'
import { snsService, isVideoUrl } from './services/snsService' import { snsService, isVideoUrl } from './services/snsService'
import { contactExportService } from './services/contactExportService' import { contactExportService } from './services/contactExportService'
import { windowsHelloService } from './services/windowsHelloService' import { windowsHelloService } from './services/windowsHelloService'
import { llamaService } from './services/llamaService'
import { registerNotificationHandlers, showNotification } from './windows/notificationWindow' import { registerNotificationHandlers, showNotification } from './windows/notificationWindow'
import { httpService } from './services/httpService' import { httpService } from './services/httpService'
@@ -87,6 +87,11 @@ const keyService = new KeyService()
let mainWindowReady = false let mainWindowReady = false
let shouldShowMain = true let shouldShowMain = true
// 更新下载状态管理Issue #294 修复)
let isDownloadInProgress = false
let downloadProgressHandler: ((progress: any) => void) | null = null
let downloadedHandler: (() => void) | null = null
function createWindow(options: { autoShow?: boolean } = {}) { function createWindow(options: { autoShow?: boolean } = {}) {
// 获取图标路径 - 打包后在 resources 目录 // 获取图标路径 - 打包后在 resources 目录
const { autoShow = true } = options const { autoShow = true } = options
@@ -173,6 +178,20 @@ function createWindow(options: { autoShow?: boolean } = {}) {
} }
) )
// 忽略微信 CDN 域名的证书错误(部分节点证书配置不正确)
win.webContents.on('certificate-error', (event, url, _error, _cert, callback) => {
const trusted = ['.qq.com', '.qpic.cn', '.weixin.qq.com', '.wechat.com']
try {
const host = new URL(url).hostname
if (trusted.some(d => host.endsWith(d))) {
event.preventDefault()
callback(true)
return
}
} catch {}
callback(false)
})
return win return win
} }
@@ -383,7 +402,7 @@ function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHe
/** /**
* 创建独立的图片查看窗口 * 创建独立的图片查看窗口
*/ */
function createImageViewerWindow(imagePath: string) { function createImageViewerWindow(imagePath: string, liveVideoPath?: string) {
const isDev = !!process.env.VITE_DEV_SERVER_URL const isDev = !!process.env.VITE_DEV_SERVER_URL
const iconPath = isDev const iconPath = isDev
? join(__dirname, '../public/icon.ico') ? join(__dirname, '../public/icon.ico')
@@ -416,7 +435,8 @@ function createImageViewerWindow(imagePath: string) {
win.show() win.show()
}) })
const imageParam = `imagePath=${encodeURIComponent(imagePath)}` let imageParam = `imagePath=${encodeURIComponent(imagePath)}`
if (liveVideoPath) imageParam += `&liveVideoPath=${encodeURIComponent(liveVideoPath)}`
if (process.env.VITE_DEV_SERVER_URL) { if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`) win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${imageParam}`)
@@ -603,22 +623,61 @@ function registerIpcHandlers() {
if (!AUTO_UPDATE_ENABLED) { if (!AUTO_UPDATE_ENABLED) {
throw new Error('自动更新已暂时禁用') throw new Error('自动更新已暂时禁用')
} }
// 防止重复下载Issue #294 修复)
if (isDownloadInProgress) {
throw new Error('更新正在下载中,请稍候')
}
isDownloadInProgress = true
const win = BrowserWindow.fromWebContents(event.sender) const win = BrowserWindow.fromWebContents(event.sender)
// 监听下载进度 // 清理旧的监听器Issue #294 修复:防止监听器泄漏)
autoUpdater.on('download-progress', (progress) => { if (downloadProgressHandler) {
win?.webContents.send('app:downloadProgress', progress) autoUpdater.removeListener('download-progress', downloadProgressHandler)
}) downloadProgressHandler = null
}
if (downloadedHandler) {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
// 下载完成后自动安装 // 创建新的监听器并保存引用
autoUpdater.on('update-downloaded', () => { downloadProgressHandler = (progress) => {
if (win && !win.isDestroyed()) {
win.webContents.send('app:downloadProgress', progress)
}
}
downloadedHandler = () => {
console.log('[Update] 更新下载完成,准备安装')
if (downloadProgressHandler) {
autoUpdater.removeListener('download-progress', downloadProgressHandler)
downloadProgressHandler = null
}
downloadedHandler = null
isDownloadInProgress = false
autoUpdater.quitAndInstall(false, true) autoUpdater.quitAndInstall(false, true)
}) }
autoUpdater.on('download-progress', downloadProgressHandler)
autoUpdater.once('update-downloaded', downloadedHandler)
try { try {
console.log('[Update] 开始下载更新...')
await autoUpdater.downloadUpdate() await autoUpdater.downloadUpdate()
} catch (error) { } catch (error) {
console.error('下载更新失败:', error) console.error('[Update] 下载更新失败:', error)
// 失败时清理状态和监听器
isDownloadInProgress = false
if (downloadProgressHandler) {
autoUpdater.removeListener('download-progress', downloadProgressHandler)
downloadProgressHandler = null
}
if (downloadedHandler) {
autoUpdater.removeListener('update-downloaded', downloadedHandler)
downloadedHandler = null
}
throw error throw error
} }
}) })
@@ -811,63 +870,6 @@ function registerIpcHandlers() {
return await chatService.getContact(username) return await chatService.getContact(username)
}) })
// Llama AI
ipcMain.handle('llama:init', async () => {
return await llamaService.init()
})
ipcMain.handle('llama:loadModel', async (_, modelPath: string) => {
return llamaService.loadModel(modelPath)
})
ipcMain.handle('llama:createSession', async (_, systemPrompt?: string) => {
return llamaService.createSession(systemPrompt)
})
ipcMain.handle('llama:chat', async (event, message: string, options?: { thinking?: boolean }) => {
// We use a callback to stream back to the renderer
const webContents = event.sender
try {
if (!webContents) return { success: false, error: 'No sender' }
const response = await llamaService.chat(message, options, (token) => {
if (!webContents.isDestroyed()) {
webContents.send('llama:token', token)
}
})
return { success: true, response }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('llama:downloadModel', async (event, url: string, savePath: string) => {
const webContents = event.sender
try {
await llamaService.downloadModel(url, savePath, (payload) => {
if (!webContents.isDestroyed()) {
webContents.send('llama:downloadProgress', payload)
}
})
return { success: true }
} catch (e) {
return { success: false, error: String(e) }
}
})
ipcMain.handle('llama:getModelsPath', async () => {
return llamaService.getModelsPath()
})
ipcMain.handle('llama:checkFileExists', async (_, filePath: string) => {
const { existsSync } = await import('fs')
return existsSync(filePath)
})
ipcMain.handle('llama:getModelStatus', async (_, modelPath: string) => {
return llamaService.getModelStatus(modelPath)
})
ipcMain.handle('chat:getContactAvatar', async (_, username: string) => { ipcMain.handle('chat:getContactAvatar', async (_, username: string) => {
return await chatService.getContactAvatar(username) return await chatService.getContactAvatar(username)
@@ -912,6 +914,9 @@ function registerIpcHandlers() {
ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => { ipcMain.handle('chat:getAllVoiceMessages', async (_, sessionId: string) => {
return chatService.getAllVoiceMessages(sessionId) return chatService.getAllVoiceMessages(sessionId)
}) })
ipcMain.handle('chat:getAllImageMessages', async (_, sessionId: string) => {
return chatService.getAllImageMessages(sessionId)
})
ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => { ipcMain.handle('chat:getMessageDates', async (_, sessionId: string) => {
return chatService.getMessageDates(sessionId) return chatService.getMessageDates(sessionId)
}) })
@@ -937,6 +942,10 @@ function registerIpcHandlers() {
return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime)
}) })
ipcMain.handle('sns:getSnsUsernames', async () => {
return snsService.getSnsUsernames()
})
ipcMain.handle('sns:debugResource', async (_, url: string) => { ipcMain.handle('sns:debugResource', async (_, url: string) => {
return snsService.debugResource(url) return snsService.debugResource(url)
}) })
@@ -983,6 +992,26 @@ function registerIpcHandlers() {
} }
}) })
ipcMain.handle('sns:exportTimeline', async (event, options: any) => {
return snsService.exportTimeline(options, (progress) => {
if (!event.sender.isDestroyed()) {
event.sender.send('sns:exportProgress', progress)
}
})
})
ipcMain.handle('sns:selectExportDir', async () => {
const { dialog } = await import('electron')
const result = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
title: '选择导出目录'
})
if (result.canceled || !result.filePaths?.[0]) {
return { canceled: true }
}
return { canceled: false, filePath: result.filePaths[0] }
})
// 私聊克隆 // 私聊克隆
@@ -1005,7 +1034,65 @@ function registerIpcHandlers() {
? mainWindow ? mainWindow
: (BrowserWindow.fromWebContents(event.sender) || undefined) : (BrowserWindow.fromWebContents(event.sender) || undefined)
return windowsHelloService.verify(message, targetWin) const result = await windowsHelloService.verify(message, targetWin)
// Hello 验证成功后,自动用 authHelloSecret 中的密码解锁密钥
if (result && configService) {
const secret = configService.getHelloSecret()
if (secret && configService.isLockMode()) {
configService.unlock(secret)
}
}
return result
})
// 验证应用锁状态(检测 lock: 前缀,防篡改)
ipcMain.handle('auth:verifyEnabled', async () => {
return configService?.verifyAuthEnabled() ?? false
})
// 密码解锁(验证 + 解密密钥到内存)
ipcMain.handle('auth:unlock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.unlock(password)
})
// 开启应用锁
ipcMain.handle('auth:enableLock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.enableLock(password)
})
// 关闭应用锁
ipcMain.handle('auth:disableLock', async (_event, password: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.disableLock(password)
})
// 修改密码
ipcMain.handle('auth:changePassword', async (_event, oldPassword: string, newPassword: string) => {
if (!configService) return { success: false, error: '配置服务未初始化' }
return configService.changePassword(oldPassword, newPassword)
})
// 设置 Hello Secret
ipcMain.handle('auth:setHelloSecret', async (_event, password: string) => {
if (!configService) return { success: false }
configService.setHelloSecret(password)
return { success: true }
})
// 清除 Hello Secret
ipcMain.handle('auth:clearHelloSecret', async () => {
if (!configService) return { success: false }
configService.clearHelloSecret()
return { success: true }
})
// 检查是否处于 lock: 模式
ipcMain.handle('auth:isLockMode', async () => {
return configService?.isLockMode() ?? false
}) })
// 导出相关 // 导出相关
@@ -1124,6 +1211,13 @@ function registerIpcHandlers() {
return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath) return groupAnalyticsService.exportGroupMembers(chatroomId, outputPath)
}) })
ipcMain.handle(
'groupAnalytics:exportGroupMemberMessages',
async (_, chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) => {
return groupAnalyticsService.exportGroupMemberMessages(chatroomId, memberUsername, outputPath, startTime, endTime)
}
)
// 打开协议窗口 // 打开协议窗口
ipcMain.handle('window:openAgreementWindow', async () => { ipcMain.handle('window:openAgreementWindow', async () => {
createAgreementWindow() createAgreementWindow()
@@ -1131,8 +1225,18 @@ function registerIpcHandlers() {
}) })
// 打开图片查看窗口 // 打开图片查看窗口
ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => { ipcMain.handle('window:openImageViewerWindow', async (_, imagePath: string, liveVideoPath?: string) => {
createImageViewerWindow(imagePath) // 如果是 dataUrl写入临时文件
if (imagePath.startsWith('data:')) {
const commaIdx = imagePath.indexOf(',')
const meta = imagePath.slice(5, commaIdx) // e.g. "image/jpeg;base64"
const ext = meta.split('/')[1]?.split(';')[0] || 'jpg'
const tmpPath = join(app.getPath('temp'), `weflow_preview_${Date.now()}.${ext}`)
await writeFile(tmpPath, Buffer.from(imagePath.slice(commaIdx + 1), 'base64'))
createImageViewerWindow(`file://${tmpPath.replace(/\\/g, '/')}`, liveVideoPath)
} else {
createImageViewerWindow(imagePath, liveVideoPath)
}
}) })
// 完成引导,关闭引导窗口并显示主窗口 // 完成引导,关闭引导窗口并显示主窗口
@@ -1358,7 +1462,8 @@ function registerIpcHandlers() {
ipcMain.handle('http:status', async () => { ipcMain.handle('http:status', async () => {
return { return {
running: httpService.isRunning(), running: httpService.isRunning(),
port: httpService.getPort() port: httpService.getPort(),
mediaExportPath: httpService.getDefaultMediaExportPath()
} }
}) })

View File

@@ -24,7 +24,15 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 认证 // 认证
auth: { auth: {
hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) hello: (message?: string) => ipcRenderer.invoke('auth:hello', message),
verifyEnabled: () => ipcRenderer.invoke('auth:verifyEnabled'),
unlock: (password: string) => ipcRenderer.invoke('auth:unlock', password),
enableLock: (password: string) => ipcRenderer.invoke('auth:enableLock', password),
disableLock: (password: string) => ipcRenderer.invoke('auth:disableLock', password),
changePassword: (oldPassword: string, newPassword: string) => ipcRenderer.invoke('auth:changePassword', oldPassword, newPassword),
setHelloSecret: (password: string) => ipcRenderer.invoke('auth:setHelloSecret', password),
clearHelloSecret: () => ipcRenderer.invoke('auth:clearHelloSecret'),
isLockMode: () => ipcRenderer.invoke('auth:isLockMode')
}, },
@@ -78,8 +86,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) => openImageViewerWindow: (imagePath: string, liveVideoPath?: string) =>
ipcRenderer.invoke('window:openImageViewerWindow', imagePath), ipcRenderer.invoke('window:openImageViewerWindow', imagePath, liveVideoPath),
openChatHistoryWindow: (sessionId: string, messageId: number) => openChatHistoryWindow: (sessionId: string, messageId: number) =>
ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId) ipcRenderer.invoke('window:openChatHistoryWindow', sessionId, messageId)
}, },
@@ -146,6 +154,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) =>
ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId),
getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId), getAllVoiceMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllVoiceMessages', sessionId),
getAllImageMessages: (sessionId: string) => ipcRenderer.invoke('chat:getAllImageMessages', sessionId),
getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId), getMessageDates: (sessionId: string) => ipcRenderer.invoke('chat:getMessageDates', sessionId),
resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId),
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime),
@@ -220,7 +229,9 @@ contextBridge.exposeInMainWorld('electronAPI', {
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) exportGroupMembers: (chatroomId: string, outputPath: string) => ipcRenderer.invoke('groupAnalytics:exportGroupMembers', chatroomId, outputPath),
exportGroupMemberMessages: (chatroomId: string, memberUsername: string, outputPath: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('groupAnalytics:exportGroupMemberMessages', chatroomId, memberUsername, outputPath, startTime, endTime)
}, },
// 年度报告 // 年度报告
@@ -274,30 +285,16 @@ contextBridge.exposeInMainWorld('electronAPI', {
sns: { sns: {
getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) =>
ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime),
getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'),
debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url), debugResource: (url: string) => ipcRenderer.invoke('sns:debugResource', url),
proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload), proxyImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:proxyImage', payload),
downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload) downloadImage: (payload: { url: string; key?: string | number }) => ipcRenderer.invoke('sns:downloadImage', payload),
}, exportTimeline: (options: any) => ipcRenderer.invoke('sns:exportTimeline', options),
onExportProgress: (callback: (payload: any) => void) => {
// Llama AI ipcRenderer.on('sns:exportProgress', (_, payload) => callback(payload))
llama: { return () => ipcRenderer.removeAllListeners('sns:exportProgress')
loadModel: (modelPath: string) => ipcRenderer.invoke('llama:loadModel', modelPath),
createSession: (systemPrompt?: string) => ipcRenderer.invoke('llama:createSession', systemPrompt),
chat: (message: string, options?: any) => ipcRenderer.invoke('llama:chat', message, options),
downloadModel: (url: string, savePath: string) => ipcRenderer.invoke('llama:downloadModel', url, savePath),
getModelsPath: () => ipcRenderer.invoke('llama:getModelsPath'),
checkFileExists: (filePath: string) => ipcRenderer.invoke('llama:checkFileExists', filePath),
getModelStatus: (modelPath: string) => ipcRenderer.invoke('llama:getModelStatus', modelPath),
onToken: (callback: (token: string) => void) => {
const listener = (_: any, token: string) => callback(token)
ipcRenderer.on('llama:token', listener)
return () => ipcRenderer.removeListener('llama:token', listener)
}, },
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => { selectExportDir: () => ipcRenderer.invoke('sns:selectExportDir')
const listener = (_: any, payload: { downloaded: number; total: number; speed: number }) => callback(payload)
ipcRenderer.on('llama:downloadProgress', listener)
return () => ipcRenderer.removeListener('llama:downloadProgress', listener)
}
}, },
// HTTP API 服务 // HTTP API 服务

View File

@@ -79,14 +79,14 @@ class AnalyticsService {
const chunkSize = 200 const chunkSize = 200
for (let i = 0; i < usernames.length; i += chunkSize) { for (let i = 0; i < usernames.length; i += chunkSize) {
const chunk = usernames.slice(i, i + chunkSize) const chunk = usernames.slice(i, i + chunkSize)
const inList = chunk.map((u) => `'${this.escapeSqlValue(u)}'`).join(',') // 使用参数化查询防止SQL注入
if (!inList) continue const placeholders = chunk.map(() => '?').join(',')
const sql = ` const sql = `
SELECT username, alias SELECT username, alias
FROM contact FROM contact
WHERE username IN (${inList}) WHERE username IN (${placeholders})
` `
const result = await wcdbService.execQuery('contact', null, sql) const result = await wcdbService.execQuery('contact', null, sql, chunk)
if (!result.success || !result.rows) continue if (!result.success || !result.rows) continue
for (const row of result.rows as Record<string, any>[]) { for (const row of result.rows as Record<string, any>[]) {
const username = row.username || '' const username = row.username || ''

View File

@@ -1,4 +1,4 @@
import { join, dirname, basename, extname } from 'path' import { join, dirname, basename, extname } from 'path'
import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync, unlinkSync, watch } 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'
@@ -13,6 +13,7 @@ import { wcdbService } from './wcdbService'
import { MessageCacheService } from './messageCacheService' import { MessageCacheService } from './messageCacheService'
import { ContactCacheService, ContactCacheEntry } from './contactCacheService' import { ContactCacheService, ContactCacheEntry } from './contactCacheService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { LRUCache } from '../utils/LRUCache.js'
type HardlinkState = { type HardlinkState = {
db: Database.Database db: Database.Database
@@ -72,9 +73,36 @@ export interface Message {
fileSize?: number // 文件大小 fileSize?: number // 文件大小
fileExt?: string // 文件扩展名 fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段 xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string
finderAvatar?: string
finderDuration?: number
// 位置消息
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
// 音乐消息
musicAlbumUrl?: string
musicUrl?: string
// 礼物消息
giftImageUrl?: string
giftWish?: string
giftPrice?: string
// 名片消息 // 名片消息
cardUsername?: string // 名片的微信ID cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称 cardNickname?: string // 名片的昵称
cardAvatarUrl?: string // 名片头像 URL
// 转账消息 // 转账消息
transferPayerUsername?: string // 转账付款人 transferPayerUsername?: string // 转账付款人
transferReceiverUsername?: string // 转账收款人 transferReceiverUsername?: string // 转账收款人
@@ -103,7 +131,7 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
// 表情包缓存 // 表情包缓存
@@ -114,6 +142,7 @@ class ChatService {
private configService: ConfigService private configService: ConfigService
private connected = false private connected = false
private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map() private messageCursors: Map<string, { cursor: number; fetched: number; batchSize: number; startTime?: number; endTime?: number; ascending?: boolean; bufferedMessages?: any[] }> = new Map()
private messageCursorMutex: boolean = false
private readonly messageBatchDefault = 50 private readonly messageBatchDefault = 50
private avatarCache: Map<string, ContactCacheEntry> private avatarCache: Map<string, ContactCacheEntry>
private readonly avatarCacheTtlMs = 10 * 60 * 1000 private readonly avatarCacheTtlMs = 10 * 60 * 1000
@@ -121,8 +150,8 @@ class ChatService {
private hardlinkCache = new Map<string, HardlinkState>() private hardlinkCache = new Map<string, HardlinkState>()
private readonly contactCacheService: ContactCacheService private readonly contactCacheService: ContactCacheService
private readonly messageCacheService: MessageCacheService private readonly messageCacheService: MessageCacheService
private voiceWavCache = new Map<string, Buffer>() private voiceWavCache: LRUCache<string, Buffer>
private voiceTranscriptCache = new Map<string, string>() private voiceTranscriptCache: LRUCache<string, string>
private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>() private voiceTranscriptPending = new Map<string, Promise<{ success: boolean; transcript?: string; error?: string }>>()
private transcriptCacheLoaded = false private transcriptCacheLoaded = false
private transcriptCacheDirty = false private transcriptCacheDirty = false
@@ -149,6 +178,9 @@ class ChatService {
const persisted = this.contactCacheService.getAllEntries() const persisted = this.contactCacheService.getAllEntries()
this.avatarCache = new Map(Object.entries(persisted)) this.avatarCache = new Map(Object.entries(persisted))
this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath()) this.messageCacheService = new MessageCacheService(this.configService.getCacheBasePath())
// 初始化LRU缓存限制大小防止内存泄漏
this.voiceWavCache = new LRUCache(this.voiceWavCacheMaxEntries)
this.voiceTranscriptCache = new LRUCache(1000) // 最多缓存1000条转写记录
} }
/** /**
@@ -603,7 +635,7 @@ class ChatService {
// 使用execQuery直接查询加密的contact.db // 使用execQuery直接查询加密的contact.db
// kind='contact', path=null表示使用已打开的contact.db // kind='contact', path=null表示使用已打开的contact.db
const contactQuery = ` const contactQuery = `
SELECT username, remark, nick_name, alias, local_type SELECT username, remark, nick_name, alias, local_type, flag, quan_pin
FROM contact FROM contact
` `
@@ -651,45 +683,23 @@ class ChatService {
for (const row of rows) { for (const row of rows) {
const username = row.username || '' const username = row.username || ''
// 过滤系统账号和特殊账号 - 完全复制cipher的逻辑
if (!username) continue if (!username) continue
if (username === 'filehelper' || username === 'fmessage' || username === 'floatbottle' ||
username === 'medianote' || username === 'newsapp' || username.startsWith('fake_') ||
username === 'weixin' || username === 'qmessage' || username === 'qqmail' ||
username === 'tmessage' || username.startsWith('wxid_') === false &&
username.includes('@') === false && username.startsWith('gh_') === false &&
/^[a-zA-Z0-9_-]+$/.test(username) === false) {
continue
}
// 判断类型 - 正确规则wxid开头且有alias的是好友 const excludeNames = ['medianote', 'floatbottle', 'qmessage', 'qqmail', 'fmessage']
let type: 'friend' | 'group' | 'official' | 'other' = 'other' let type: 'friend' | 'group' | 'official' | 'former_friend' | 'other' = 'other'
const localType = row.local_type || 0 const localType = this.getRowInt(row, ['local_type', 'localType', 'WCDB_CT_local_type'], 0)
const flag = Number(row.flag ?? 0)
const quanPin = this.getRowField(row, ['quan_pin', 'quanPin', 'WCDB_CT_quan_pin']) || ''
if (username.includes('@chatroom')) { if (username.includes('@chatroom')) {
type = 'group' type = 'group'
} else if (username.startsWith('gh_')) { } else if (username.startsWith('gh_')) {
type = 'official' type = 'official'
} else if (localType === 3 || localType === 4) { } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 1 && !excludeNames.includes(username)) {
type = 'official'
} else if (username.startsWith('wxid_') && row.alias) {
// wxid开头且有alias的是好友
type = 'friend' type = 'friend'
} else if (localType === 1) { } else if (/^(?!.*(gh_|@chatroom)).*$/.test(username) && localType === 0 && quanPin) {
// local_type=1 也是好友 type = 'former_friend'
type = 'friend'
} else if (localType === 2) {
// local_type=2 是群成员但非好友,跳过
continue
} else if (localType === 0) {
// local_type=0 可能是好友或其他,检查是否有备注或昵称
if (row.remark || row.nick_name) {
type = 'friend'
} else {
continue
}
} else { } else {
// 其他未知类型,跳过
continue continue
} }
@@ -750,6 +760,13 @@ class ChatService {
} }
const batchSize = Math.max(1, limit || this.messageBatchDefault) const batchSize = Math.max(1, limit || this.messageBatchDefault)
// 使用互斥锁保护游标状态访问
while (this.messageCursorMutex) {
await new Promise(resolve => setTimeout(resolve, 1))
}
this.messageCursorMutex = true
let state = this.messageCursors.get(sessionId) let state = this.messageCursors.get(sessionId)
// 只在以下情况重新创建游标: // 只在以下情况重新创建游标:
@@ -787,6 +804,7 @@ class ChatService {
state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending } state = { cursor: cursorResult.cursor, fetched: 0, batchSize, startTime, endTime, ascending }
this.messageCursors.set(sessionId, state) this.messageCursors.set(sessionId, state)
this.messageCursorMutex = false
// 如果需要跳过消息(offset > 0),逐批获取但不返回 // 如果需要跳过消息(offset > 0),逐批获取但不返回
// 注意:仅在 offset === 0 时重建游标最安全; // 注意:仅在 offset === 0 时重建游标最安全;
@@ -847,6 +865,10 @@ class ChatService {
let rows: any[] = state.bufferedMessages || [] let rows: any[] = state.bufferedMessages || []
state.bufferedMessages = undefined // Clear buffer after use state.bufferedMessages = undefined // Clear buffer after use
// Track actual hasMore status from C++ layer
// If we have buffered messages, we need to check if there's more data
let actualHasMore = rows.length > 0 // If buffer exists, assume there might be more
// If buffer is not enough to fill a batch, try to fetch more // If buffer is not enough to fill a batch, try to fetch more
// Or if buffer is empty, fetch a batch // Or if buffer is empty, fetch a batch
if (rows.length < batchSize) { if (rows.length < batchSize) {
@@ -854,6 +876,7 @@ class ChatService {
if (nextBatch.success && nextBatch.rows) { if (nextBatch.success && nextBatch.rows) {
rows = rows.concat(nextBatch.rows) rows = rows.concat(nextBatch.rows)
state.fetched += nextBatch.rows.length state.fetched += nextBatch.rows.length
actualHasMore = nextBatch.hasMore === true
} else if (!nextBatch.success) { } else if (!nextBatch.success) {
console.error('[ChatService] 获取消息批次失败:', nextBatch.error) console.error('[ChatService] 获取消息批次失败:', nextBatch.error)
// If we have some buffered rows, we can still return them? // If we have some buffered rows, we can still return them?
@@ -861,6 +884,7 @@ class ChatService {
if (rows.length === 0) { if (rows.length === 0) {
return { success: false, error: nextBatch.error || '获取消息失败' } return { success: false, error: nextBatch.error || '获取消息失败' }
} }
actualHasMore = false
} }
} }
@@ -871,13 +895,43 @@ class ChatService {
// Next time offset will catch up or mismatch trigger reset. // Next time offset will catch up or mismatch trigger reset.
} }
const hasMore = rows.length > 0 // Simplified hasMore check for now, can be improved // Use actual hasMore from C++ layer, not simplified row count check
const hasMore = actualHasMore
const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows)) const normalized = this.normalizeMessageOrder(this.mapRowsToMessages(rows))
// 🔒 安全验证:过滤掉不属于当前 sessionId 的消息(防止 C++ 层或缓存错误)
const filtered = normalized.filter(msg => {
// 检查消息的 senderUsername 或 rawContent 中的 talker
// 群聊消息senderUsername 是群成员,需要检查 _db_path 或上下文
// 单聊消息senderUsername 应该是 sessionId 或自己
const isGroupChat = sessionId.includes('@chatroom')
if (isGroupChat) {
// 群聊消息暂不验证(因为 senderUsername 是群成员,不是 sessionId
return true
} else {
// 单聊消息senderUsername 应该是 sessionId对方或为空/null自己
if (!msg.senderUsername || msg.senderUsername === sessionId) {
return true
}
// 如果 isSend 为 1说明是自己发的允许通过
if (msg.isSend === 1) {
return true
}
// 其他情况:可能是错误的消息
console.warn(`[ChatService] 检测到异常消息: sessionId=${sessionId}, senderUsername=${msg.senderUsername}, localId=${msg.localId}`)
return false
}
})
if (filtered.length < normalized.length) {
console.warn(`[ChatService] 过滤了 ${normalized.length - filtered.length} 条异常消息`)
}
// 并发检查并修复缺失 CDN URL 的表情包 // 并发检查并修复缺失 CDN URL 的表情包
const fixPromises: Promise<void>[] = [] const fixPromises: Promise<void>[] = []
for (const msg of normalized) { for (const msg of filtered) {
if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) { if (msg.localType === 47 && !msg.emojiCdnUrl && msg.emojiMd5) {
fixPromises.push(this.fallbackEmoticon(msg)) fixPromises.push(this.fallbackEmoticon(msg))
} }
@@ -888,9 +942,12 @@ class ChatService {
} }
state.fetched += rows.length state.fetched += rows.length
this.messageCacheService.set(sessionId, normalized) this.messageCursorMutex = false
return { success: true, messages: normalized, hasMore }
this.messageCacheService.set(sessionId, filtered)
return { success: true, messages: filtered, hasMore }
} catch (e) { } catch (e) {
this.messageCursorMutex = false
console.error('ChatService: 获取消息失败:', e) console.error('ChatService: 获取消息失败:', e)
return { success: false, error: String(e) } return { success: false, error: String(e) }
} }
@@ -1112,6 +1169,13 @@ class ChatService {
return Number.isFinite(parsed) ? parsed : NaN return Number.isFinite(parsed) ? parsed : NaN
} }
/**
* HTTP API 复用消息解析逻辑,确保和应用内展示一致。
*/
mapRowsToMessagesForApi(rows: Record<string, any>[]): Message[] {
return this.mapRowsToMessages(rows)
}
private mapRowsToMessages(rows: Record<string, any>[]): Message[] { private mapRowsToMessages(rows: Record<string, any>[]): Message[] {
const myWxid = this.configService.get('myWxid') const myWxid = this.configService.get('myWxid')
const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null const cleanedWxid = myWxid ? this.cleanAccountDirName(myWxid) : null
@@ -1187,9 +1251,33 @@ class ChatService {
let fileSize: number | undefined let fileSize: number | undefined
let fileExt: string | undefined let fileExt: string | undefined
let xmlType: string | undefined let xmlType: string | undefined
let appMsgKind: string | undefined
let appMsgDesc: string | undefined
let appMsgAppName: string | undefined
let appMsgSourceName: string | undefined
let appMsgSourceUsername: string | undefined
let appMsgThumbUrl: string | undefined
let appMsgMusicUrl: string | undefined
let appMsgDataUrl: string | undefined
let appMsgLocationLabel: string | undefined
let finderNickname: string | undefined
let finderUsername: string | undefined
let finderCoverUrl: string | undefined
let finderAvatar: string | undefined
let finderDuration: number | undefined
let locationLat: number | undefined
let locationLng: number | undefined
let locationPoiname: string | undefined
let locationLabel: string | undefined
let musicAlbumUrl: string | undefined
let musicUrl: string | undefined
let giftImageUrl: string | undefined
let giftWish: string | undefined
let giftPrice: string | undefined
// 名片消息 // 名片消息
let cardUsername: string | undefined let cardUsername: string | undefined
let cardNickname: string | undefined let cardNickname: string | undefined
let cardAvatarUrl: string | undefined
// 转账消息 // 转账消息
let transferPayerUsername: string | undefined let transferPayerUsername: string | undefined
let transferReceiverUsername: string | undefined let transferReceiverUsername: string | undefined
@@ -1227,6 +1315,15 @@ class ChatService {
const cardInfo = this.parseCardInfo(content) const cardInfo = this.parseCardInfo(content)
cardUsername = cardInfo.username cardUsername = cardInfo.username
cardNickname = cardInfo.nickname cardNickname = cardInfo.nickname
cardAvatarUrl = cardInfo.avatarUrl
} else if (localType === 48 && content) {
// 位置消息
const latStr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
const lngStr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
if (latStr) { const v = parseFloat(latStr); if (Number.isFinite(v)) locationLat = v }
if (lngStr) { const v = parseFloat(lngStr); if (Number.isFinite(v)) locationLng = v }
locationLabel = this.extractXmlAttribute(content, 'location', 'label') || this.extractXmlValue(content, 'label') || undefined
locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || this.extractXmlValue(content, 'poiname') || undefined
} else if ((localType === 49 || localType === 8589934592049) && content) { } else if ((localType === 49 || localType === 8589934592049) && content) {
// Type 49 消息链接、文件、小程序、转账等8589934592049 也是转账类型 // Type 49 消息链接、文件、小程序、转账等8589934592049 也是转账类型
const type49Info = this.parseType49Message(content) const type49Info = this.parseType49Message(content)
@@ -1247,6 +1344,45 @@ class ChatService {
quotedSender = quoteInfo.sender quotedSender = quoteInfo.sender
} }
const looksLikeAppMsg = Boolean(content && (content.includes('<appmsg') || content.includes('&lt;appmsg')))
if (looksLikeAppMsg) {
const type49Info = this.parseType49Message(content)
xmlType = xmlType || type49Info.xmlType
linkTitle = linkTitle || type49Info.linkTitle
linkUrl = linkUrl || type49Info.linkUrl
linkThumb = linkThumb || type49Info.linkThumb
fileName = fileName || type49Info.fileName
fileSize = fileSize ?? type49Info.fileSize
fileExt = fileExt || type49Info.fileExt
appMsgKind = appMsgKind || type49Info.appMsgKind
appMsgDesc = appMsgDesc || type49Info.appMsgDesc
appMsgAppName = appMsgAppName || type49Info.appMsgAppName
appMsgSourceName = appMsgSourceName || type49Info.appMsgSourceName
appMsgSourceUsername = appMsgSourceUsername || type49Info.appMsgSourceUsername
appMsgThumbUrl = appMsgThumbUrl || type49Info.appMsgThumbUrl
appMsgMusicUrl = appMsgMusicUrl || type49Info.appMsgMusicUrl
appMsgDataUrl = appMsgDataUrl || type49Info.appMsgDataUrl
appMsgLocationLabel = appMsgLocationLabel || type49Info.appMsgLocationLabel
finderNickname = finderNickname || type49Info.finderNickname
finderUsername = finderUsername || type49Info.finderUsername
finderCoverUrl = finderCoverUrl || type49Info.finderCoverUrl
finderAvatar = finderAvatar || type49Info.finderAvatar
finderDuration = finderDuration ?? type49Info.finderDuration
locationLat = locationLat ?? type49Info.locationLat
locationLng = locationLng ?? type49Info.locationLng
locationPoiname = locationPoiname || type49Info.locationPoiname
locationLabel = locationLabel || type49Info.locationLabel
musicAlbumUrl = musicAlbumUrl || type49Info.musicAlbumUrl
musicUrl = musicUrl || type49Info.musicUrl
giftImageUrl = giftImageUrl || type49Info.giftImageUrl
giftWish = giftWish || type49Info.giftWish
giftPrice = giftPrice || type49Info.giftPrice
chatRecordTitle = chatRecordTitle || type49Info.chatRecordTitle
chatRecordList = chatRecordList || type49Info.chatRecordList
transferPayerUsername = transferPayerUsername || type49Info.transferPayerUsername
transferReceiverUsername = transferReceiverUsername || type49Info.transferReceiverUsername
}
messages.push({ messages.push({
localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0), localId: this.getRowInt(row, ['local_id', 'localId', 'LocalId', 'msg_local_id', 'msgLocalId', 'MsgLocalId', 'msg_id', 'msgId', 'MsgId', 'id', 'WCDB_CT_local_id'], 0),
serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0), serverId: this.getRowInt(row, ['server_id', 'serverId', 'ServerId', 'msg_server_id', 'msgServerId', 'MsgServerId', 'WCDB_CT_server_id'], 0),
@@ -1275,8 +1411,32 @@ class ChatService {
fileSize, fileSize,
fileExt, fileExt,
xmlType, xmlType,
appMsgKind,
appMsgDesc,
appMsgAppName,
appMsgSourceName,
appMsgSourceUsername,
appMsgThumbUrl,
appMsgMusicUrl,
appMsgDataUrl,
appMsgLocationLabel,
finderNickname,
finderUsername,
finderCoverUrl,
finderAvatar,
finderDuration,
locationLat,
locationLng,
locationPoiname,
locationLabel,
musicAlbumUrl,
musicUrl,
giftImageUrl,
giftWish,
giftPrice,
cardUsername, cardUsername,
cardNickname, cardNickname,
cardAvatarUrl,
transferPayerUsername, transferPayerUsername,
transferReceiverUsername, transferReceiverUsername,
chatRecordTitle, chatRecordTitle,
@@ -1313,6 +1473,7 @@ class ChatService {
// 检查 XML type用于识别引用消息等 // 检查 XML type用于识别引用消息等
const xmlType = this.extractXmlValue(content, 'type') const xmlType = this.extractXmlValue(content, 'type')
const looksLikeAppMsg = content.includes('<appmsg') || content.includes('&lt;appmsg')
switch (localType) { switch (localType) {
case 1: case 1:
@@ -1327,8 +1488,14 @@ class ChatService {
return '[视频]' return '[视频]'
case 47: case 47:
return '[动画表情]' return '[动画表情]'
case 48: case 48: {
return '[位置]' const label =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
return label ? `[位置] ${label}` : '[位置]'
}
case 49: case 49:
return this.parseType49(content) return this.parseType49(content)
case 50: case 50:
@@ -1363,6 +1530,10 @@ class ChatService {
return title || '[引用消息]' return title || '[引用消息]'
} }
if (looksLikeAppMsg) {
return this.parseType49(content)
}
// 尝试从 XML 提取通用 title // 尝试从 XML 提取通用 title
const genericTitle = this.extractXmlValue(content, 'title') const genericTitle = this.extractXmlValue(content, 'title')
if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) { if (genericTitle && genericTitle.length > 0 && genericTitle.length < 100) {
@@ -1379,6 +1550,23 @@ class ChatService {
private parseType49(content: string): string { private parseType49(content: string): string {
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
const type = this.extractXmlValue(content, 'type') const type = this.extractXmlValue(content, 'type')
const normalized = content.toLowerCase()
const locationLabel =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
const isFinder =
type === '51' ||
normalized.includes('<finder') ||
normalized.includes('finderusername') ||
normalized.includes('finderobjectid')
const isRedPacket = type === '2001' || normalized.includes('hongbao')
const isMusic =
type === '3' ||
normalized.includes('<musicurl>') ||
normalized.includes('<playurl>') ||
normalized.includes('<dataurl>')
// 群公告消息type 87特殊处理 // 群公告消息type 87特殊处理
if (type === '87') { if (type === '87') {
@@ -1389,6 +1577,19 @@ class ChatService {
return '[群公告]' return '[群公告]'
} }
if (isFinder) {
return title ? `[视频号] ${title}` : '[视频号]'
}
if (isRedPacket) {
return title ? `[红包] ${title}` : '[红包]'
}
if (locationLabel) {
return `[位置] ${locationLabel}`
}
if (isMusic) {
return title ? `[音乐] ${title}` : '[音乐]'
}
if (title) { if (title) {
switch (type) { switch (type) {
case '5': case '5':
@@ -1406,6 +1607,8 @@ class ChatService {
return title return title
case '2000': case '2000':
return `[转账] ${title}` return `[转账] ${title}`
case '2001':
return `[红包] ${title}`
default: default:
return title return title
} }
@@ -1422,6 +1625,13 @@ class ChatService {
return '[小程序]' return '[小程序]'
case '2000': case '2000':
return '[转账]' return '[转账]'
case '2001':
return '[红包]'
case '3':
return '[音乐]'
case '5':
case '49':
return '[链接]'
case '87': case '87':
return '[群公告]' return '[群公告]'
default: default:
@@ -1727,7 +1937,7 @@ class ChatService {
* 解析名片消息 * 解析名片消息
* 格式: <msg username="wxid_xxx" nickname="昵称" ... /> * 格式: <msg username="wxid_xxx" nickname="昵称" ... />
*/ */
private parseCardInfo(content: string): { username?: string; nickname?: string } { private parseCardInfo(content: string): { username?: string; nickname?: string; avatarUrl?: string } {
try { try {
if (!content) return {} if (!content) return {}
@@ -1737,7 +1947,11 @@ class ChatService {
// 提取 nickname // 提取 nickname
const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined const nickname = this.extractXmlAttribute(content, 'msg', 'nickname') || undefined
return { username, nickname } // 提取头像
const avatarUrl = this.extractXmlAttribute(content, 'msg', 'bigheadimgurl') ||
this.extractXmlAttribute(content, 'msg', 'smallheadimgurl') || undefined
return { username, nickname, avatarUrl }
} catch (e) { } catch (e) {
console.error('[ChatService] 名片解析失败:', e) console.error('[ChatService] 名片解析失败:', e)
return {} return {}
@@ -1753,6 +1967,30 @@ class ChatService {
linkTitle?: string linkTitle?: string
linkUrl?: string linkUrl?: string
linkThumb?: string linkThumb?: string
appMsgKind?: string
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string
finderAvatar?: string
finderDuration?: number
locationLat?: number
locationLng?: number
locationPoiname?: string
locationLabel?: string
musicAlbumUrl?: string
musicUrl?: string
giftImageUrl?: string
giftWish?: string
giftPrice?: string
cardAvatarUrl?: string
fileName?: string fileName?: string
fileSize?: number fileSize?: number
fileExt?: string fileExt?: string
@@ -1779,6 +2017,122 @@ class ChatService {
// 提取通用字段 // 提取通用字段
const title = this.extractXmlValue(content, 'title') const title = this.extractXmlValue(content, 'title')
const url = this.extractXmlValue(content, 'url') const url = this.extractXmlValue(content, 'url')
const desc = this.extractXmlValue(content, 'des') || this.extractXmlValue(content, 'description')
const appName = this.extractXmlValue(content, 'appname')
const sourceName = this.extractXmlValue(content, 'sourcename')
const sourceUsername = this.extractXmlValue(content, 'sourceusername')
const thumbUrl =
this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'cdnthumburl') ||
this.extractXmlValue(content, 'cover') ||
this.extractXmlValue(content, 'coverurl') ||
this.extractXmlValue(content, 'thumb_url')
const musicUrl =
this.extractXmlValue(content, 'musicurl') ||
this.extractXmlValue(content, 'playurl') ||
this.extractXmlValue(content, 'songalbumurl')
const dataUrl = this.extractXmlValue(content, 'dataurl') || this.extractXmlValue(content, 'lowurl')
const locationLabel =
this.extractXmlAttribute(content, 'location', 'label') ||
this.extractXmlAttribute(content, 'location', 'poiname') ||
this.extractXmlValue(content, 'label') ||
this.extractXmlValue(content, 'poiname')
const finderUsername =
this.extractXmlValue(content, 'finderusername') ||
this.extractXmlValue(content, 'finder_username') ||
this.extractXmlValue(content, 'finderuser')
const finderNickname =
this.extractXmlValue(content, 'findernickname') ||
this.extractXmlValue(content, 'finder_nickname')
const normalized = content.toLowerCase()
const isFinder = xmlType === '51'
const isRedPacket = xmlType === '2001'
const isMusic = xmlType === '3'
const isLocation = Boolean(locationLabel)
result.linkTitle = title || undefined
result.linkUrl = url || undefined
result.linkThumb = thumbUrl || undefined
result.appMsgDesc = desc || undefined
result.appMsgAppName = appName || undefined
result.appMsgSourceName = sourceName || undefined
result.appMsgSourceUsername = sourceUsername || undefined
result.appMsgThumbUrl = thumbUrl || undefined
result.appMsgMusicUrl = musicUrl || undefined
result.appMsgDataUrl = dataUrl || undefined
result.appMsgLocationLabel = locationLabel || undefined
result.finderUsername = finderUsername || undefined
result.finderNickname = finderNickname || undefined
// 视频号封面/头像/时长
if (isFinder) {
const finderCover =
this.extractXmlValue(content, 'thumbUrl') ||
this.extractXmlValue(content, 'coverUrl') ||
this.extractXmlValue(content, 'thumburl') ||
this.extractXmlValue(content, 'coverurl')
if (finderCover) result.finderCoverUrl = finderCover
const finderAvatar = this.extractXmlValue(content, 'avatar')
if (finderAvatar) result.finderAvatar = finderAvatar
const durationStr = this.extractXmlValue(content, 'videoPlayDuration') || this.extractXmlValue(content, 'duration')
if (durationStr) {
const d = parseInt(durationStr, 10)
if (Number.isFinite(d) && d > 0) result.finderDuration = d
}
}
// 位置经纬度
if (isLocation) {
const latAttr = this.extractXmlAttribute(content, 'location', 'x') || this.extractXmlAttribute(content, 'location', 'latitude')
const lngAttr = this.extractXmlAttribute(content, 'location', 'y') || this.extractXmlAttribute(content, 'location', 'longitude')
if (latAttr) { const v = parseFloat(latAttr); if (Number.isFinite(v)) result.locationLat = v }
if (lngAttr) { const v = parseFloat(lngAttr); if (Number.isFinite(v)) result.locationLng = v }
result.locationPoiname = this.extractXmlAttribute(content, 'location', 'poiname') || locationLabel || undefined
result.locationLabel = this.extractXmlAttribute(content, 'location', 'label') || undefined
}
// 音乐专辑封面
if (isMusic) {
const albumUrl = this.extractXmlValue(content, 'songalbumurl')
if (albumUrl) result.musicAlbumUrl = albumUrl
result.musicUrl = musicUrl || dataUrl || url || undefined
}
// 礼物消息
const isGift = xmlType === '115'
if (isGift) {
result.giftWish = this.extractXmlValue(content, 'wishmessage') || undefined
result.giftImageUrl = this.extractXmlValue(content, 'skuimgurl') || undefined
result.giftPrice = this.extractXmlValue(content, 'skuprice') || undefined
}
if (isFinder) {
result.appMsgKind = 'finder'
} else if (isRedPacket) {
result.appMsgKind = 'red-packet'
} else if (isGift) {
result.appMsgKind = 'gift'
} else if (isLocation) {
result.appMsgKind = 'location'
} else if (isMusic) {
result.appMsgKind = 'music'
} else if (xmlType === '33' || xmlType === '36') {
result.appMsgKind = 'miniapp'
} else if (xmlType === '6') {
result.appMsgKind = 'file'
} else if (xmlType === '19') {
result.appMsgKind = 'chat-record'
} else if (xmlType === '2000') {
result.appMsgKind = 'transfer'
} else if (xmlType === '87') {
result.appMsgKind = 'announcement'
} else if ((xmlType === '5' || xmlType === '49') && (sourceUsername?.startsWith('gh_') || appName?.includes('公众号') || sourceName)) {
result.appMsgKind = 'official-link'
} else if (url) {
result.appMsgKind = 'link'
} else {
result.appMsgKind = 'card'
}
switch (xmlType) { switch (xmlType) {
case '6': { case '6': {
@@ -3713,10 +4067,7 @@ class ChatService {
private cacheVoiceWav(cacheKey: string, wavData: Buffer): void { private cacheVoiceWav(cacheKey: string, wavData: Buffer): void {
this.voiceWavCache.set(cacheKey, wavData) this.voiceWavCache.set(cacheKey, wavData)
if (this.voiceWavCache.size > this.voiceWavCacheMaxEntries) { // LRU缓存会自动处理大小限制无需手动清理
const oldestKey = this.voiceWavCache.keys().next().value
if (oldestKey) this.voiceWavCache.delete(oldestKey)
}
} }
/** 获取持久化转写缓存文件路径 */ /** 获取持久化转写缓存文件路径 */
@@ -3850,6 +4201,74 @@ class ChatService {
* 获取某会话中有消息的日期列表 * 获取某会话中有消息的日期列表
* 返回 YYYY-MM-DD 格式的日期字符串数组 * 返回 YYYY-MM-DD 格式的日期字符串数组
*/ */
/**
* 获取某会话的全部图片消息(用于聊天页批量图片解密)
*/
async getAllImageMessages(
sessionId: string
): Promise<{ success: boolean; images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]; error?: string }> {
try {
const connectResult = await this.ensureConnected()
if (!connectResult.success) {
return { success: false, error: connectResult.error || '数据库未连接' }
}
let tables = this.sessionTablesCache.get(sessionId)
if (!tables) {
const tableStats = await wcdbService.getMessageTableStats(sessionId)
if (!tableStats.success || !tableStats.tables || tableStats.tables.length === 0) {
return { success: false, error: '未找到会话消息表' }
}
tables = tableStats.tables
.map(t => ({ tableName: t.table_name || t.name, dbPath: t.db_path }))
.filter(t => t.tableName && t.dbPath) as Array<{ tableName: string; dbPath: string }>
if (tables.length > 0) {
this.sessionTablesCache.set(sessionId, tables)
setTimeout(() => { this.sessionTablesCache.delete(sessionId) }, this.sessionTablesCacheTtl)
}
}
let allImages: Array<{ imageMd5?: string; imageDatName?: string; createTime?: number }> = []
for (const { tableName, dbPath } of tables) {
try {
const sql = `SELECT * FROM ${tableName} WHERE local_type = 3 ORDER BY create_time DESC`
const result = await wcdbService.execQuery('message', dbPath, sql)
if (result.success && result.rows && result.rows.length > 0) {
const mapped = this.mapRowsToMessages(result.rows as Record<string, any>[])
const images = mapped
.filter(msg => msg.localType === 3)
.map(msg => ({
imageMd5: msg.imageMd5 || undefined,
imageDatName: msg.imageDatName || undefined,
createTime: msg.createTime || undefined
}))
.filter(img => Boolean(img.imageMd5 || img.imageDatName))
allImages.push(...images)
}
} catch (e) {
console.error(`[ChatService] 查询图片消息失败 (${dbPath}):`, e)
}
}
allImages.sort((a, b) => (b.createTime || 0) - (a.createTime || 0))
const seen = new Set<string>()
allImages = allImages.filter(img => {
const key = img.imageMd5 || img.imageDatName || ''
if (!key || seen.has(key)) return false
seen.add(key)
return true
})
console.log(`[ChatService] 共找到 ${allImages.length} 条图片消息(去重后)`)
return { success: true, images: allImages }
} catch (e) {
console.error('[ChatService] 获取全部图片消息失败:', e)
return { success: false, error: String(e) }
}
}
async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> { async getMessageDates(sessionId: string): Promise<{ success: boolean; dates?: string[]; error?: string }> {
try { try {
const connectResult = await this.ensureConnected() const connectResult = await this.ensureConnected()
@@ -3983,6 +4402,15 @@ class ChatService {
msg.emojiThumbUrl = emojiInfo.thumbUrl msg.emojiThumbUrl = emojiInfo.thumbUrl
msg.emojiEncryptUrl = emojiInfo.encryptUrl msg.emojiEncryptUrl = emojiInfo.encryptUrl
msg.emojiAesKey = emojiInfo.aesKey msg.emojiAesKey = emojiInfo.aesKey
} else if (msg.localType === 42) {
const cardInfo = this.parseCardInfo(rawContent)
msg.cardUsername = cardInfo.username
msg.cardNickname = cardInfo.nickname
msg.cardAvatarUrl = cardInfo.avatarUrl
}
if (rawContent && (rawContent.includes('<appmsg') || rawContent.includes('&lt;appmsg'))) {
Object.assign(msg, this.parseType49Message(rawContent))
} }
return msg return msg

View File

@@ -1,12 +1,17 @@
import { join } from 'path' import { join } from 'path'
import { app } from 'electron' import { app, safeStorage } from 'electron'
import crypto from 'crypto'
import Store from 'electron-store' import Store from 'electron-store'
// 加密前缀标记
const SAFE_PREFIX = 'safe:' // safeStorage 加密(普通模式)
const LOCK_PREFIX = 'lock:' // 密码派生密钥加密(锁定模式)
interface ConfigSchema { interface ConfigSchema {
// 数据库相关 // 数据库相关
dbPath: string // 数据库根目录 (xwechat_files) dbPath: string
decryptKey: string // 解密密钥 decryptKey: string
myWxid: string // 当前用户 wxid myWxid: string
onboardingDone: boolean onboardingDone: boolean
imageXorKey: number imageXorKey: number
imageAesKey: string imageAesKey: string
@@ -14,7 +19,6 @@ interface ConfigSchema {
// 缓存相关 // 缓存相关
cachePath: string cachePath: string
lastOpenedDb: string lastOpenedDb: string
lastSession: string lastSession: string
@@ -34,8 +38,9 @@ interface ConfigSchema {
// 安全相关 // 安全相关
authEnabled: boolean authEnabled: boolean
authPassword: string // SHA-256 hash authPassword: string // SHA-256 hashsafeStorage 加密)
authUseHello: boolean authUseHello: boolean
authHelloSecret: string // 原始密码safeStorage 加密Hello 解锁时使用)
// 更新相关 // 更新相关
ignoredUpdateVersion: string ignoredUpdateVersion: string
@@ -48,10 +53,23 @@ interface ConfigSchema {
wordCloudExcludeWords: string[] wordCloudExcludeWords: string[]
} }
// 需要 safeStorage 加密的字段(普通模式)
const ENCRYPTED_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey', 'authPassword'])
const ENCRYPTED_BOOL_KEYS: Set<string> = new Set(['authEnabled', 'authUseHello'])
const ENCRYPTED_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
// 需要与密码绑定的敏感密钥字段(锁定模式时用 lock: 加密)
const LOCKABLE_STRING_KEYS: Set<string> = new Set(['decryptKey', 'imageAesKey'])
const LOCKABLE_NUMBER_KEYS: Set<string> = new Set(['imageXorKey'])
export class ConfigService { export class ConfigService {
private static instance: ConfigService private static instance: ConfigService
private store!: Store<ConfigSchema> private store!: Store<ConfigSchema>
// 锁定模式运行时状态
private unlockedKeys: Map<string, any> = new Map()
private unlockPassword: string | null = null
static getInstance(): ConfigService { static getInstance(): ConfigService {
if (!ConfigService.instance) { if (!ConfigService.instance) {
ConfigService.instance = new ConfigService() ConfigService.instance = new ConfigService()
@@ -75,7 +93,6 @@ export class ConfigService {
imageAesKey: '', imageAesKey: '',
wxidConfigs: {}, wxidConfigs: {},
cachePath: '', cachePath: '',
lastOpenedDb: '', lastOpenedDb: '',
lastSession: '', lastSession: '',
theme: 'system', theme: 'system',
@@ -90,11 +107,10 @@ export class ConfigService {
transcribeLanguages: ['zh'], transcribeLanguages: ['zh'],
exportDefaultConcurrency: 2, exportDefaultConcurrency: 2,
analyticsExcludedUsernames: [], analyticsExcludedUsernames: [],
authEnabled: false, authEnabled: false,
authPassword: '', authPassword: '',
authUseHello: false, authUseHello: false,
authHelloSecret: '',
ignoredUpdateVersion: '', ignoredUpdateVersion: '',
notificationEnabled: true, notificationEnabled: true,
notificationPosition: 'top-right', notificationPosition: 'top-right',
@@ -103,29 +119,535 @@ export class ConfigService {
wordCloudExcludeWords: [] wordCloudExcludeWords: []
} }
}) })
this.migrateAuthFields()
} }
// === 状态查询 ===
isLockMode(): boolean {
const raw: any = this.store.get('decryptKey')
return typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)
}
isUnlocked(): boolean {
return !this.isLockMode() || this.unlockedKeys.size > 0
}
// === get / set ===
get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] { get<K extends keyof ConfigSchema>(key: K): ConfigSchema[K] {
return this.store.get(key) const raw = this.store.get(key)
if (ENCRYPTED_BOOL_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str || !str.startsWith(SAFE_PREFIX)) return raw
return (this.safeDecrypt(str) === 'true') as ConfigSchema[K]
}
if (ENCRYPTED_NUMBER_KEYS.has(key)) {
const str = typeof raw === 'string' ? raw : ''
if (!str) return raw
if (str.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : 0) as ConfigSchema[K]
}
if (!str.startsWith(SAFE_PREFIX)) return raw
const num = Number(this.safeDecrypt(str))
return (Number.isFinite(num) ? num : 0) as ConfigSchema[K]
}
if (ENCRYPTED_STRING_KEYS.has(key) && typeof raw === 'string') {
if (key === 'authPassword') return this.safeDecrypt(raw) as ConfigSchema[K]
if (raw.startsWith(LOCK_PREFIX)) {
const cached = this.unlockedKeys.get(key as string)
return (cached !== undefined ? cached : '') as ConfigSchema[K]
}
return this.safeDecrypt(raw) as ConfigSchema[K]
}
if (key === 'wxidConfigs' && raw && typeof raw === 'object') {
return this.decryptWxidConfigs(raw as any) as ConfigSchema[K]
}
return raw
} }
set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void { set<K extends keyof ConfigSchema>(key: K, value: ConfigSchema[K]): void {
this.store.set(key, value) let toStore = value
const inLockMode = this.isLockMode() && this.unlockPassword
if (ENCRYPTED_BOOL_KEYS.has(key)) {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
} else if (ENCRYPTED_NUMBER_KEYS.has(key)) {
if (inLockMode && LOCKABLE_NUMBER_KEYS.has(key)) {
toStore = this.lockEncrypt(String(value), this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(String(value)) as ConfigSchema[K]
}
} else if (ENCRYPTED_STRING_KEYS.has(key) && typeof value === 'string') {
if (key === 'authPassword') {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
} else if (inLockMode && LOCKABLE_STRING_KEYS.has(key)) {
toStore = this.lockEncrypt(value, this.unlockPassword!) as ConfigSchema[K]
this.unlockedKeys.set(key as string, value)
} else {
toStore = this.safeEncrypt(value) as ConfigSchema[K]
}
} else if (key === 'wxidConfigs' && value && typeof value === 'object') {
if (inLockMode) {
toStore = this.lockEncryptWxidConfigs(value as any) as ConfigSchema[K]
} else {
toStore = this.encryptWxidConfigs(value as any) as ConfigSchema[K]
}
}
this.store.set(key, toStore)
} }
// === 加密/解密工具 ===
private safeEncrypt(plaintext: string): string {
if (!plaintext) return ''
if (plaintext.startsWith(SAFE_PREFIX)) return plaintext
if (!safeStorage.isEncryptionAvailable()) return plaintext
const encrypted = safeStorage.encryptString(plaintext)
return SAFE_PREFIX + encrypted.toString('base64')
}
private safeDecrypt(stored: string): string {
if (!stored) return ''
if (!stored.startsWith(SAFE_PREFIX)) return stored
if (!safeStorage.isEncryptionAvailable()) return ''
try {
const buf = Buffer.from(stored.slice(SAFE_PREFIX.length), 'base64')
return safeStorage.decryptString(buf)
} catch {
return ''
}
}
private lockEncrypt(plaintext: string, password: string): string {
if (!plaintext) return ''
const salt = crypto.randomBytes(16)
const iv = crypto.randomBytes(12)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
const combined = Buffer.concat([salt, iv, authTag, encrypted])
return LOCK_PREFIX + combined.toString('base64')
}
private lockDecrypt(stored: string, password: string): string | null {
if (!stored || !stored.startsWith(LOCK_PREFIX)) return null
try {
const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
const salt = combined.subarray(0, 16)
const iv = combined.subarray(16, 28)
const authTag = combined.subarray(28, 44)
const ciphertext = combined.subarray(44)
const derivedKey = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha256')
const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
decipher.setAuthTag(authTag)
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
return decrypted.toString('utf8')
} catch {
return null
}
}
// 通过尝试解密 lock: 字段来验证密码是否正确(当 authPassword 被删除时使用)
private verifyPasswordByDecrypt(password: string): boolean {
// 依次尝试解密任意一个 lock: 字段GCM authTag 会验证密码正确性
const lockFields = ['decryptKey', 'imageAesKey', 'imageXorKey'] as const
for (const key of lockFields) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
const result = this.lockDecrypt(raw, password)
// lockDecrypt 返回 null 表示解密失败(密码错误),非 null 表示成功
return result !== null
}
}
return false
}
// === wxidConfigs 加密/解密 ===
private encryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.safeEncrypt(cfg.decryptKey)
if (cfg.imageAesKey) result[wxid].imageAesKey = this.safeEncrypt(cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
}
}
return result
}
private decryptLockedWxidConfigs(password: string): void {
const wxidConfigs = this.store.get('wxidConfigs')
if (!wxidConfigs || typeof wxidConfigs !== 'object') return
for (const [wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && cfg.decryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.decryptKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, d)
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageAesKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, d)
}
if (cfg.imageXorKey && typeof cfg.imageXorKey === 'string' && cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(cfg.imageXorKey, password)
if (d !== null) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, Number(d))
}
}
}
private decryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs) as [string, any][]) {
result[wxid] = { ...cfg, updatedAt: cfg.updatedAt }
// decryptKey
if (typeof cfg.decryptKey === 'string') {
if (cfg.decryptKey.startsWith(LOCK_PREFIX)) {
result[wxid].decryptKey = this.unlockedKeys.get(`wxid:${wxid}:decryptKey`) ?? ''
} else {
result[wxid].decryptKey = this.safeDecrypt(cfg.decryptKey)
}
}
// imageAesKey
if (typeof cfg.imageAesKey === 'string') {
if (cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageAesKey = this.unlockedKeys.get(`wxid:${wxid}:imageAesKey`) ?? ''
} else {
result[wxid].imageAesKey = this.safeDecrypt(cfg.imageAesKey)
}
}
// imageXorKey
if (typeof cfg.imageXorKey === 'string') {
if (cfg.imageXorKey.startsWith(LOCK_PREFIX)) {
result[wxid].imageXorKey = this.unlockedKeys.get(`wxid:${wxid}:imageXorKey`) ?? 0
} else if (cfg.imageXorKey.startsWith(SAFE_PREFIX)) {
const num = Number(this.safeDecrypt(cfg.imageXorKey))
result[wxid].imageXorKey = Number.isFinite(num) ? num : 0
}
}
}
return result
}
private lockEncryptWxidConfigs(configs: ConfigSchema['wxidConfigs']): ConfigSchema['wxidConfigs'] {
const result: ConfigSchema['wxidConfigs'] = {}
for (const [wxid, cfg] of Object.entries(configs)) {
result[wxid] = { ...cfg }
if (cfg.decryptKey) result[wxid].decryptKey = this.lockEncrypt(cfg.decryptKey, this.unlockPassword!) as any
if (cfg.imageAesKey) result[wxid].imageAesKey = this.lockEncrypt(cfg.imageAesKey, this.unlockPassword!) as any
if (cfg.imageXorKey !== undefined) {
(result[wxid] as any).imageXorKey = this.lockEncrypt(String(cfg.imageXorKey), this.unlockPassword!)
}
}
return result
}
// === 业务方法 ===
enableLock(password: string): { success: boolean; error?: string } {
try {
// 先读取当前所有明文密钥
const decryptKey = this.get('decryptKey')
const imageAesKey = this.get('imageAesKey')
const imageXorKey = this.get('imageXorKey')
const wxidConfigs = this.get('wxidConfigs')
// 存储密码 hashsafeStorage 加密)
const passwordHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(passwordHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
// 设置运行时状态
this.unlockPassword = password
this.unlockedKeys.set('decryptKey', decryptKey)
this.unlockedKeys.set('imageAesKey', imageAesKey)
this.unlockedKeys.set('imageXorKey', imageXorKey)
// 用密码派生密钥重新加密所有敏感字段
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), password) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), password) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), password) as any)
// 处理 wxidConfigs 中的嵌套密钥
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
for (const [wxid, cfg] of Object.entries(wxidConfigs)) {
if (cfg.decryptKey) this.unlockedKeys.set(`wxid:${wxid}:decryptKey`, cfg.decryptKey)
if (cfg.imageAesKey) this.unlockedKeys.set(`wxid:${wxid}:imageAesKey`, cfg.imageAesKey)
if (cfg.imageXorKey !== undefined) this.unlockedKeys.set(`wxid:${wxid}:imageXorKey`, cfg.imageXorKey)
}
}
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
unlock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash && storedHash !== inputHash) {
// authPassword 存在但密码不匹配
return { success: false, error: '密码错误' }
}
if (!storedHash) {
// authPassword 被删除/损坏,尝试用密码直接解密 lock: 字段来验证
const verified = this.verifyPasswordByDecrypt(password)
if (!verified) {
return { success: false, error: '密码错误' }
}
// 密码正确,自愈 authPassword
const newHash = crypto.createHash('sha256').update(password).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
this.store.set('authEnabled', this.safeEncrypt('true') as any)
}
// 解密所有 lock: 字段到内存缓存
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawDecryptKey, password)
if (d !== null) this.unlockedKeys.set('decryptKey', d)
}
const rawImageAesKey: any = this.store.get('imageAesKey')
if (typeof rawImageAesKey === 'string' && rawImageAesKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageAesKey, password)
if (d !== null) this.unlockedKeys.set('imageAesKey', d)
}
const rawImageXorKey: any = this.store.get('imageXorKey')
if (typeof rawImageXorKey === 'string' && rawImageXorKey.startsWith(LOCK_PREFIX)) {
const d = this.lockDecrypt(rawImageXorKey, password)
if (d !== null) this.unlockedKeys.set('imageXorKey', Number(d))
}
// 解密 wxidConfigs 嵌套密钥
this.decryptLockedWxidConfigs(password)
// 保留密码供 set() 使用
this.unlockPassword = password
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
disableLock(password: string): { success: boolean; error?: string } {
try {
// 验证密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const inputHash = crypto.createHash('sha256').update(password).digest('hex')
if (storedHash !== inputHash) {
return { success: false, error: '密码错误' }
}
// 先解密所有 lock: 字段
if (this.unlockedKeys.size === 0) {
this.unlock(password)
}
// 将所有密钥转回 safe: 格式
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.safeEncrypt(String(decryptKey)) as any)
if (imageAesKey) this.store.set('imageAesKey', this.safeEncrypt(String(imageAesKey)) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.safeEncrypt(String(imageXorKey)) as any)
// 转换 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
const safeConfigs = this.encryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', safeConfigs)
}
// 清除 auth 字段
this.store.set('authEnabled', false as any)
this.store.set('authPassword', '' as any)
this.store.set('authUseHello', false as any)
this.store.set('authHelloSecret', '' as any)
// 清除运行时状态
this.unlockedKeys.clear()
this.unlockPassword = null
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
changePassword(oldPassword: string, newPassword: string): { success: boolean; error?: string } {
try {
// 验证旧密码
const storedHash = this.safeDecrypt(this.store.get('authPassword') as any)
const oldHash = crypto.createHash('sha256').update(oldPassword).digest('hex')
if (storedHash !== oldHash) {
return { success: false, error: '旧密码错误' }
}
// 确保已解锁
if (this.unlockedKeys.size === 0) {
this.unlock(oldPassword)
}
// 用新密码重新加密所有密钥
const decryptKey = this.unlockedKeys.get('decryptKey')
const imageAesKey = this.unlockedKeys.get('imageAesKey')
const imageXorKey = this.unlockedKeys.get('imageXorKey')
if (decryptKey) this.store.set('decryptKey', this.lockEncrypt(String(decryptKey), newPassword) as any)
if (imageAesKey) this.store.set('imageAesKey', this.lockEncrypt(String(imageAesKey), newPassword) as any)
if (imageXorKey !== undefined) this.store.set('imageXorKey', this.lockEncrypt(String(imageXorKey), newPassword) as any)
// 重新加密 wxidConfigs
const wxidConfigs = this.get('wxidConfigs')
if (wxidConfigs && Object.keys(wxidConfigs).length > 0) {
this.unlockPassword = newPassword
const lockedConfigs = this.lockEncryptWxidConfigs(wxidConfigs)
this.store.set('wxidConfigs', lockedConfigs)
}
// 更新密码 hash
const newHash = crypto.createHash('sha256').update(newPassword).digest('hex')
this.store.set('authPassword', this.safeEncrypt(newHash) as any)
// 更新 Hello secret如果启用了 Hello
const useHello = this.get('authUseHello')
if (useHello) {
this.store.set('authHelloSecret', this.safeEncrypt(newPassword) as any)
}
this.unlockPassword = newPassword
return { success: true }
} catch (e: any) {
return { success: false, error: e.message }
}
}
// === Hello 相关 ===
setHelloSecret(password: string): void {
this.store.set('authHelloSecret', this.safeEncrypt(password) as any)
this.store.set('authUseHello', this.safeEncrypt('true') as any)
}
getHelloSecret(): string {
const raw: any = this.store.get('authHelloSecret')
if (!raw || typeof raw !== 'string') return ''
return this.safeDecrypt(raw)
}
clearHelloSecret(): void {
this.store.set('authHelloSecret', '' as any)
this.store.set('authUseHello', this.safeEncrypt('false') as any)
}
// === 迁移 ===
private migrateAuthFields(): void {
// 将旧版明文 auth 字段迁移为 safeStorage 加密格式
// 如果已经是 safe: 或 lock: 前缀则跳过
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'boolean') {
this.store.set('authEnabled', this.safeEncrypt(String(rawEnabled)) as any)
}
const rawUseHello: any = this.store.get('authUseHello')
if (typeof rawUseHello === 'boolean') {
this.store.set('authUseHello', this.safeEncrypt(String(rawUseHello)) as any)
}
const rawPassword: any = this.store.get('authPassword')
if (typeof rawPassword === 'string' && rawPassword && !rawPassword.startsWith(SAFE_PREFIX)) {
this.store.set('authPassword', this.safeEncrypt(rawPassword) as any)
}
// 迁移敏感密钥字段(明文 → safe:
for (const key of LOCKABLE_STRING_KEYS) {
const raw: any = this.store.get(key as any)
if (typeof raw === 'string' && raw && !raw.startsWith(SAFE_PREFIX) && !raw.startsWith(LOCK_PREFIX)) {
this.store.set(key as any, this.safeEncrypt(raw) as any)
}
}
// imageXorKey: 数字 → safe:
const rawXor: any = this.store.get('imageXorKey')
if (typeof rawXor === 'number' && rawXor !== 0) {
this.store.set('imageXorKey', this.safeEncrypt(String(rawXor)) as any)
}
// wxidConfigs 中的嵌套密钥
const wxidConfigs: any = this.store.get('wxidConfigs')
if (wxidConfigs && typeof wxidConfigs === 'object') {
let changed = false
for (const [_wxid, cfg] of Object.entries(wxidConfigs) as [string, any][]) {
if (cfg.decryptKey && typeof cfg.decryptKey === 'string' && !cfg.decryptKey.startsWith(SAFE_PREFIX) && !cfg.decryptKey.startsWith(LOCK_PREFIX)) {
cfg.decryptKey = this.safeEncrypt(cfg.decryptKey)
changed = true
}
if (cfg.imageAesKey && typeof cfg.imageAesKey === 'string' && !cfg.imageAesKey.startsWith(SAFE_PREFIX) && !cfg.imageAesKey.startsWith(LOCK_PREFIX)) {
cfg.imageAesKey = this.safeEncrypt(cfg.imageAesKey)
changed = true
}
if (typeof cfg.imageXorKey === 'number' && cfg.imageXorKey !== 0) {
cfg.imageXorKey = this.safeEncrypt(String(cfg.imageXorKey))
changed = true
}
}
if (changed) {
this.store.set('wxidConfigs', wxidConfigs)
}
}
}
// === 验证 ===
verifyAuthEnabled(): boolean {
// 先检查 authEnabled 字段
const rawEnabled: any = this.store.get('authEnabled')
if (typeof rawEnabled === 'string' && rawEnabled.startsWith(SAFE_PREFIX)) {
if (this.safeDecrypt(rawEnabled) === 'true') return true
}
// 即使 authEnabled 被删除/篡改,如果密钥是 lock: 格式,说明曾开启过应用锁
const rawDecryptKey: any = this.store.get('decryptKey')
if (typeof rawDecryptKey === 'string' && rawDecryptKey.startsWith(LOCK_PREFIX)) {
return true
}
return false
}
// === 工具方法 ===
getCacheBasePath(): string { getCacheBasePath(): string {
const configured = this.get('cachePath') return join(app.getPath('userData'), 'cache')
if (configured && configured.trim().length > 0) {
return configured
}
return join(app.getPath('documents'), 'WeFlow')
} }
getAll(): ConfigSchema { getAll(): Partial<ConfigSchema> {
return this.store.store return this.store.store
} }
clear(): void { clear(): void {
this.store.clear() this.store.clear()
this.unlockedKeys.clear()
this.unlockPassword = null
} }
} }

View File

@@ -10,6 +10,7 @@ interface ContactExportOptions {
groups: boolean groups: boolean
officials: boolean officials: boolean
} }
selectedUsernames?: string[]
} }
/** /**
@@ -40,6 +41,11 @@ class ContactExportService {
return true return true
}) })
if (Array.isArray(options.selectedUsernames) && options.selectedUsernames.length > 0) {
const selectedSet = new Set(options.selectedUsernames)
contacts = contacts.filter(c => selectedSet.has(c.username))
}
if (contacts.length === 0) { if (contacts.length === 0) {
return { success: false, error: '没有符合条件的联系人' } return { success: false, error: '没有符合条件的联系人' }
} }

View File

@@ -186,6 +186,17 @@ body {
word-break: break-word; word-break: break-word;
} }
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji { .inline-emoji {
width: 22px; width: 22px;
height: 22px; height: 22px;

View File

@@ -186,6 +186,17 @@ body {
word-break: break-word; word-break: break-word;
} }
.message-link-card {
color: #2563eb;
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.message-link-card:hover {
color: #1d4ed8;
}
.inline-emoji { .inline-emoji {
width: 22px; width: 22px;
height: 22px; height: 22px;

View File

@@ -12,6 +12,7 @@ import { chatService } from './chatService'
import { videoService } from './videoService' import { videoService } from './videoService'
import { voiceTranscribeService } from './voiceTranscribeService' import { voiceTranscribeService } from './voiceTranscribeService'
import { EXPORT_HTML_STYLES } from './exportHtmlStyles' import { EXPORT_HTML_STYLES } from './exportHtmlStyles'
import { LRUCache } from '../utils/LRUCache.js'
// ChatLab 格式类型定义 // ChatLab 格式类型定义
interface ChatLabHeader { interface ChatLabHeader {
@@ -70,6 +71,8 @@ const MESSAGE_TYPE_MAP: Record<number, number> = {
export interface ExportOptions { export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql' format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
exportMedia?: boolean exportMedia?: boolean
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: boolean exportImages?: boolean
@@ -138,12 +141,15 @@ async function parallelLimit<T, R>(
class ExportService { class ExportService {
private configService: ConfigService private configService: ConfigService
private contactCache: Map<string, { displayName: string; avatarUrl?: string }> = new Map() private contactCache: LRUCache<string, { displayName: string; avatarUrl?: string }>
private inlineEmojiCache: Map<string, string> = new Map() private inlineEmojiCache: LRUCache<string, string>
private htmlStyleCache: string | null = null private htmlStyleCache: string | null = null
constructor() { constructor() {
this.configService = new ConfigService() this.configService = new ConfigService()
// 限制缓存大小,防止内存泄漏
this.contactCache = new LRUCache(500) // 最多缓存500个联系人
this.inlineEmojiCache = new LRUCache(100) // 最多缓存100个表情
} }
private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number { private getClampedConcurrency(value: number | undefined, fallback = 2, max = 6): number {
@@ -217,9 +223,9 @@ class ExportService {
*/ */
async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> { async getGroupNicknamesForRoom(chatroomId: string, candidates: string[] = []): Promise<Map<string, string>> {
try { try {
const escapedChatroomId = chatroomId.replace(/'/g, "''") // 使用参数化查询防止SQL注入
const sql = `SELECT ext_buffer FROM chat_room WHERE username='${escapedChatroomId}' LIMIT 1` const sql = 'SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1'
const result = await wcdbService.execQuery('contact', null, sql) const result = await wcdbService.execQuery('contact', null, sql, [chatroomId])
if (!result.success || !result.rows || result.rows.length === 0) { if (!result.success || !result.rows || result.rows.length === 0) {
return new Map<string, string>() return new Map<string, string>()
} }
@@ -534,11 +540,14 @@ class ExportService {
groupNicknamesMap: Map<string, string>, groupNicknamesMap: Map<string, string>,
getContactName: (username: string) => Promise<string> getContactName: (username: string) => Promise<string>
): Promise<string | null> { ): Promise<string | null> {
const xmlType = this.extractXmlValue(content, 'type') const normalizedContent = this.normalizeAppMessageContent(content || '')
if (xmlType !== '2000') return null if (!normalizedContent) return null
const payerUsername = this.extractXmlValue(content, 'payer_username') const xmlType = this.extractXmlValue(normalizedContent, 'type')
const receiverUsername = this.extractXmlValue(content, 'receiver_username') if (xmlType && xmlType !== '2000') return null
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
if (!payerUsername || !receiverUsername) return null if (!payerUsername || !receiverUsername) return null
const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : '' const cleanedMyWxid = myWxid ? this.cleanAccountDirName(myWxid) : ''
@@ -565,6 +574,52 @@ class ExportService {
return `${payerName} 转账给 ${receiverName}` return `${payerName} 转账给 ${receiverName}`
} }
private isSameWxid(lhs?: string, rhs?: string): boolean {
const left = new Set(this.buildGroupNicknameIdCandidates([lhs]).map((id) => id.toLowerCase()))
if (left.size === 0) return false
const right = this.buildGroupNicknameIdCandidates([rhs]).map((id) => id.toLowerCase())
return right.some((id) => left.has(id))
}
private getTransferPrefix(content: string, myWxid?: string, senderWxid?: string, isSend?: boolean): '[转账]' | '[转账收款]' {
const normalizedContent = this.normalizeAppMessageContent(content || '')
if (!normalizedContent) return '[转账]'
const paySubtype = this.extractXmlValue(normalizedContent, 'paysubtype')
// 转账消息在部分账号数据中 `payer_username` 可能为空,优先用 `paysubtype` 判定
// 实测1=发起侧3=收款侧
if (paySubtype === '3') return '[转账收款]'
if (paySubtype === '1') return '[转账]'
const payerUsername = this.extractXmlValue(normalizedContent, 'payer_username')
const receiverUsername = this.extractXmlValue(normalizedContent, 'receiver_username')
const senderIsPayer = senderWxid ? this.isSameWxid(senderWxid, payerUsername) : false
const senderIsReceiver = senderWxid ? this.isSameWxid(senderWxid, receiverUsername) : false
// 实测字段语义sender 命中 receiver_username 为转账发起侧,命中 payer_username 为收款侧
if (senderWxid) {
if (senderIsReceiver && !senderIsPayer) return '[转账]'
if (senderIsPayer && !senderIsReceiver) return '[转账收款]'
}
// 兜底:按当前账号角色判断
if (myWxid) {
if (this.isSameWxid(myWxid, receiverUsername)) return '[转账]'
if (this.isSameWxid(myWxid, payerUsername)) return '[转账收款]'
}
return '[转账]'
}
private isTransferExportContent(content: string): boolean {
return content.startsWith('[转账]') || content.startsWith('[转账收款]')
}
private appendTransferDesc(content: string, transferDesc: string): string {
const prefix = content.startsWith('[转账收款]') ? '[转账收款]' : '[转账]'
return content.replace(prefix, `${prefix} (${transferDesc})`)
}
private looksLikeBase64(s: string): boolean { private looksLikeBase64(s: string): boolean {
if (s.length % 4 !== 0) return false if (s.length % 4 !== 0) return false
return /^[A-Za-z0-9+/=]+$/.test(s) return /^[A-Za-z0-9+/=]+$/.test(s)
@@ -574,7 +629,15 @@ class ExportService {
* 解析消息内容为可读文本 * 解析消息内容为可读文本
* 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理 * 注意:语音消息在这里返回占位符,实际转文字在导出时异步处理
*/ */
private parseMessageContent(content: string, localType: number, sessionId?: string, createTime?: number): string | null { private parseMessageContent(
content: string,
localType: number,
sessionId?: string,
createTime?: number,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
): string | null {
if (!content) return null if (!content) return null
// 检查 XML 中的 type 标签(支持大 localType 的情况) // 检查 XML 中的 type 标签(支持大 localType 的情况)
@@ -611,10 +674,11 @@ class ExportService {
if (type === '2000') { if (type === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc') const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo') const payMemo = this.extractXmlValue(content, 'pay_memo')
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
if (feedesc) { if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
} }
return '[转账]' return transferPrefix
} }
if (type === '6') return title ? `[文件] ${title}` : '[文件]' if (type === '6') return title ? `[文件] ${title}` : '[文件]'
@@ -650,10 +714,11 @@ class ExportService {
if (xmlType === '2000') { if (xmlType === '2000') {
const feedesc = this.extractXmlValue(content, 'feedesc') const feedesc = this.extractXmlValue(content, 'feedesc')
const payMemo = this.extractXmlValue(content, 'pay_memo') const payMemo = this.extractXmlValue(content, 'pay_memo')
const transferPrefix = this.getTransferPrefix(content, myWxid, senderWxid, isSend)
if (feedesc) { if (feedesc) {
return payMemo ? `[转账] ${feedesc} ${payMemo}` : `[转账] ${feedesc}` return payMemo ? `${transferPrefix} ${feedesc} ${payMemo}` : `${transferPrefix} ${feedesc}`
} }
return '[转账]' return transferPrefix
} }
// 其他类型 // 其他类型
@@ -676,7 +741,10 @@ class ExportService {
content: string, content: string,
localType: number, localType: number,
options: { exportVoiceAsText?: boolean }, options: { exportVoiceAsText?: boolean },
voiceTranscript?: string voiceTranscript?: string,
myWxid?: string,
senderWxid?: string,
isSend?: boolean
): string { ): string {
const safeContent = content || '' const safeContent = content || ''
@@ -742,8 +810,9 @@ class ExportService {
if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) { if (subType === 2000 || title.includes('转账') || normalized.includes('transfer')) {
const feedesc = this.extractXmlValue(normalized, 'feedesc') const feedesc = this.extractXmlValue(normalized, 'feedesc')
const payMemo = this.extractXmlValue(normalized, 'pay_memo') const payMemo = this.extractXmlValue(normalized, 'pay_memo')
const transferPrefix = this.getTransferPrefix(normalized, myWxid, senderWxid, isSend)
if (feedesc) { if (feedesc) {
return payMemo ? `[转账]${feedesc} ${payMemo}` : `[转账]${feedesc}` return payMemo ? `${transferPrefix}${feedesc} ${payMemo}` : `${transferPrefix}${feedesc}`
} }
const amount = this.extractAmountFromText( const amount = this.extractAmountFromText(
[ [
@@ -756,7 +825,7 @@ class ExportService {
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
) )
return amount ? `[转账]${amount}` : '[转账]' return amount ? `${transferPrefix}${amount}` : transferPrefix
} }
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) { if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
@@ -1256,7 +1325,7 @@ class ExportService {
return rendered.join('') return rendered.join('')
} }
private formatHtmlMessageText(content: string, localType: number): string { private formatHtmlMessageText(content: string, localType: number, myWxid?: string, senderWxid?: string, isSend?: boolean): string {
if (!content) return '' if (!content) return ''
if (localType === 1) { if (localType === 1) {
@@ -1264,10 +1333,59 @@ class ExportService {
} }
if (localType === 34) { if (localType === 34) {
return this.parseMessageContent(content, localType) || '' return this.parseMessageContent(content, localType, undefined, undefined, myWxid, senderWxid, isSend) || ''
} }
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }) return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false }, undefined, myWxid, senderWxid, isSend)
}
private extractHtmlLinkCard(content: string, localType: number): { title: string; url: string } | null {
if (!content) return null
const normalized = this.normalizeAppMessageContent(content)
const isAppMessage = localType === 49 || normalized.includes('<appmsg') || normalized.includes('<msg>')
if (!isAppMessage) return null
const subType = this.extractXmlValue(normalized, 'type')
if (subType && subType !== '5' && subType !== '49') return null
const url = this.normalizeHtmlLinkUrl(this.extractXmlValue(normalized, 'url'))
if (!url) return null
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'des') || url
return { title, url }
}
private normalizeHtmlLinkUrl(rawUrl: string): string {
const value = (rawUrl || '').trim()
if (!value) return ''
const parseHttpUrl = (candidate: string): string => {
try {
const parsed = new URL(candidate)
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.toString()
}
} catch {
return ''
}
return ''
}
if (value.startsWith('//')) {
return parseHttpUrl(`https:${value}`)
}
const direct = parseHttpUrl(value)
if (direct) return direct
const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(value)
const isDomainLike = /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?:[/:?#].*)?$/.test(value)
if (!hasScheme && isDomainLike) {
return parseHttpUrl(`https://${value}`)
}
return ''
} }
/** /**
@@ -1353,6 +1471,7 @@ class ExportService {
}) })
if (!result.success || !result.localPath) { if (!result.success || !result.localPath) {
console.log(`[Export] 图片解密失败 (localId=${msg.localId}): imageMd5=${imageMd5}, imageDatName=${imageDatName}, error=${result.error || '未知'}`)
// 尝试获取缩略图 // 尝试获取缩略图
const thumbResult = await imageDecryptService.resolveCachedImage({ const thumbResult = await imageDecryptService.resolveCachedImage({
sessionId, sessionId,
@@ -1360,18 +1479,24 @@ class ExportService {
imageDatName imageDatName
}) })
if (!thumbResult.success || !thumbResult.localPath) { if (!thumbResult.success || !thumbResult.localPath) {
console.log(`[Export] 缩略图也获取失败 (localId=${msg.localId}): error=${thumbResult.error || '未知'} → 将显示 [图片] 占位符`)
return null return null
} }
console.log(`[Export] 使用缩略图替代 (localId=${msg.localId}): ${thumbResult.localPath}`)
result.localPath = thumbResult.localPath result.localPath = thumbResult.localPath
} }
// 为每条消息生成稳定且唯一的文件名前缀,避免跨日期/消息发生同名覆盖
const messageId = String(msg.localId || Date.now())
const imageKey = (imageMd5 || imageDatName || 'image').replace(/[^a-zA-Z0-9_-]/g, '')
// 从 data URL 或 file URL 获取实际路径 // 从 data URL 或 file URL 获取实际路径
let sourcePath = result.localPath let sourcePath = result.localPath
if (sourcePath.startsWith('data:')) { if (sourcePath.startsWith('data:')) {
// 是 data URL需要保存为文件 // 是 data URL需要保存为文件
const base64Data = sourcePath.split(',')[1] const base64Data = sourcePath.split(',')[1]
const ext = this.getExtFromDataUrl(sourcePath) const ext = this.getExtFromDataUrl(sourcePath)
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName) const destPath = path.join(imagesDir, fileName)
fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64')) fs.writeFileSync(destPath, Buffer.from(base64Data, 'base64'))
@@ -1385,9 +1510,12 @@ class ExportService {
} }
// 复制文件 // 复制文件
if (!fs.existsSync(sourcePath)) return null if (!fs.existsSync(sourcePath)) {
console.log(`[Export] 源图片文件不存在 (localId=${msg.localId}): ${sourcePath} → 将显示 [图片] 占位符`)
return null
}
const ext = path.extname(sourcePath) || '.jpg' const ext = path.extname(sourcePath) || '.jpg'
const fileName = `${imageMd5 || imageDatName || msg.localId}${ext}` const fileName = `${messageId}_${imageKey}${ext}`
const destPath = path.join(imagesDir, fileName) const destPath = path.join(imagesDir, fileName)
if (!fs.existsSync(destPath)) { if (!fs.existsSync(destPath)) {
@@ -1399,6 +1527,7 @@ class ExportService {
kind: 'image' kind: 'image'
} }
} catch (e) { } catch (e) {
console.error(`[Export] 导出图片异常 (localId=${msg.localId}):`, e, `→ 将显示 [图片] 占位符`)
return null return null
} }
} }
@@ -1667,7 +1796,14 @@ class ExportService {
fileStream.close() fileStream.close()
resolve(true) resolve(true)
}) })
fileStream.on('error', () => { fileStream.on('error', (err) => {
// 确保在错误情况下销毁流,释放文件句柄
fileStream.destroy()
resolve(false)
})
response.on('error', (err) => {
// 确保在响应错误时也关闭文件句柄
fileStream.destroy()
resolve(false) resolve(false)
}) })
}) })
@@ -1685,7 +1821,8 @@ class ExportService {
private async collectMessages( private async collectMessages(
sessionId: string, sessionId: string,
cleanedMyWxid: string, cleanedMyWxid: string,
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null,
senderUsernameFilter?: string
): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> { ): Promise<{ rows: any[]; memberSet: Map<string, { member: ChatLabMember; avatarUrl?: string }>; firstTime: number | null; lastTime: number | null }> {
const rows: any[] = [] const rows: any[] = []
const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>() const memberSet = new Map<string, { member: ChatLabMember; avatarUrl?: string }>()
@@ -1693,22 +1830,43 @@ class ExportService {
let firstTime: number | null = null let firstTime: number | null = null
let lastTime: number | null = null let lastTime: number | null = null
// 修复时间范围0 表示不限制,而不是时间戳 0
const beginTime = dateRange?.start || 0
const endTime = dateRange?.end && dateRange.end > 0 ? dateRange.end : 0
console.log(`[Export] 收集消息: sessionId=${sessionId}, 时间范围: ${beginTime} ~ ${endTime || '无限制'}`)
const cursor = await wcdbService.openMessageCursor( const cursor = await wcdbService.openMessageCursor(
sessionId, sessionId,
500, 500,
true, true,
dateRange?.start || 0, beginTime,
dateRange?.end || 0 endTime
) )
if (!cursor.success || !cursor.cursor) { if (!cursor.success || !cursor.cursor) {
console.error(`[Export] 打开游标失败: ${cursor.error || '未知错误'}`)
return { rows, memberSet, firstTime, lastTime } return { rows, memberSet, firstTime, lastTime }
} }
try { try {
let hasMore = true let hasMore = true
let batchCount = 0
while (hasMore) { while (hasMore) {
const batch = await wcdbService.fetchMessageBatch(cursor.cursor) const batch = await wcdbService.fetchMessageBatch(cursor.cursor)
if (!batch.success || !batch.rows) break batchCount++
if (!batch.success) {
console.error(`[Export] 获取批次 ${batchCount} 失败: ${batch.error}`)
break
}
if (!batch.rows) {
console.warn(`[Export] 批次 ${batchCount} 无数据`)
break
}
console.log(`[Export] 批次 ${batchCount}: 收到 ${batch.rows.length} 条消息`)
for (const row of batch.rows) { for (const row of batch.rows) {
const createTime = parseInt(row.create_time || '0', 10) const createTime = parseInt(row.create_time || '0', 10)
if (dateRange) { if (dateRange) {
@@ -1746,6 +1904,10 @@ class ExportService {
} else { } else {
actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId) actualSender = isSend ? cleanedMyWxid : (senderUsername || sessionId)
} }
if (senderUsernameFilter && !this.isSameWxid(actualSender, senderUsernameFilter)) {
continue
}
senderSet.add(actualSender) senderSet.add(actualSender)
// 提取媒体相关字段 // 提取媒体相关字段
@@ -1795,8 +1957,17 @@ class ExportService {
} }
hasMore = batch.hasMore === true hasMore = batch.hasMore === true
} }
console.log(`[Export] 收集完成: 共 ${rows.length} 条消息, ${batchCount} 个批次`)
} catch (err) {
console.error(`[Export] 收集消息异常:`, err)
} finally { } finally {
await wcdbService.closeMessageCursor(cursor.cursor) try {
await wcdbService.closeMessageCursor(cursor.cursor)
console.log(`[Export] 游标已关闭`)
} catch (err) {
console.error(`[Export] 关闭游标失败:`, err)
}
} }
if (senderSet.size > 0) { if (senderSet.size > 0) {
@@ -2174,7 +2345,7 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const allMessages = collected.rows const allMessages = collected.rows
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
@@ -2335,11 +2506,19 @@ class ExportService {
// 使用预先转写的文字 // 使用预先转写的文字
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else { } else {
content = this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) content = this.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
)
} }
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
if (content && content.startsWith('[转账]') && msg.content) { if (content && this.isTransferExportContent(content) && msg.content) {
const transferDesc = await this.resolveTransferDesc( const transferDesc = await this.resolveTransferDesc(
msg.content, msg.content,
cleanedMyWxid, cleanedMyWxid,
@@ -2350,7 +2529,7 @@ class ExportService {
} }
) )
if (transferDesc) { if (transferDesc) {
content = content.replace('[转账]', `[转账] (${transferDesc})`) content = this.appendTransferDesc(content, transferDesc)
} }
} }
@@ -2561,7 +2740,7 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
@@ -2705,11 +2884,19 @@ class ExportService {
} else if (mediaItem) { } else if (mediaItem) {
content = mediaItem.relativePath content = mediaItem.relativePath
} else { } else {
content = this.parseMessageContent(msg.content, msg.localType) content = this.parseMessageContent(
msg.content,
msg.localType,
undefined,
undefined,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
)
} }
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
if (content && content.startsWith('[转账]') && msg.content) { if (content && this.isTransferExportContent(content) && msg.content) {
const transferDesc = await this.resolveTransferDesc( const transferDesc = await this.resolveTransferDesc(
msg.content, msg.content,
cleanedMyWxid, cleanedMyWxid,
@@ -2723,7 +2910,7 @@ class ExportService {
} }
) )
if (transferDesc) { if (transferDesc) {
content = content.replace('[转账]', `[转账] (${transferDesc})`) content = this.appendTransferDesc(content, transferDesc)
} }
} }
@@ -2887,7 +3074,7 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
@@ -3196,19 +3383,25 @@ class ExportService {
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
) )
: (mediaItem?.relativePath : (mediaItem?.relativePath
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue let enrichedContentValue = contentValue
if (contentValue.startsWith('[转账]') && msg.content) { if (this.isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await this.resolveTransferDesc( const transferDesc = await this.resolveTransferDesc(
msg.content, msg.content,
cleanedMyWxid, cleanedMyWxid,
@@ -3222,7 +3415,7 @@ class ExportService {
} }
) )
if (transferDesc) { if (transferDesc) {
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
} }
} }
@@ -3368,7 +3561,7 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
@@ -3507,19 +3700,25 @@ class ExportService {
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
) )
: (mediaItem?.relativePath : (mediaItem?.relativePath
|| this.formatPlainExportContent( || this.formatPlainExportContent(
msg.content, msg.content,
msg.localType, msg.localType,
options, options,
voiceTranscriptMap.get(msg.localId) voiceTranscriptMap.get(msg.localId),
cleanedMyWxid,
msg.senderUsername,
msg.isSend
)) ))
// 转账消息:追加 "谁转账给谁" 信息 // 转账消息:追加 "谁转账给谁" 信息
let enrichedContentValue = contentValue let enrichedContentValue = contentValue
if (contentValue.startsWith('[转账]') && msg.content) { if (this.isTransferExportContent(contentValue) && msg.content) {
const transferDesc = await this.resolveTransferDesc( const transferDesc = await this.resolveTransferDesc(
msg.content, msg.content,
cleanedMyWxid, cleanedMyWxid,
@@ -3533,7 +3732,7 @@ class ExportService {
} }
) )
if (transferDesc) { if (transferDesc) {
enrichedContentValue = contentValue.replace('[转账]', `[转账] (${transferDesc})`) enrichedContentValue = this.appendTransferDesc(contentValue, transferDesc)
} }
} }
@@ -3642,7 +3841,7 @@ class ExportService {
phase: 'preparing' phase: 'preparing'
}) })
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
@@ -3805,7 +4004,15 @@ class ExportService {
const msgText = msg.localType === 34 && options.exportVoiceAsText const msgText = msg.localType === 34 && options.exportVoiceAsText
? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]') ? (voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]')
: (this.parseMessageContent(msg.content, msg.localType, sessionId, msg.createTime) || '') : (this.parseMessageContent(
msg.content,
msg.localType,
sessionId,
msg.createTime,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
) || '')
const src = this.getWeCloneSource(msg, typeName, mediaItem) const src = this.getWeCloneSource(msg, typeName, mediaItem)
const row = [ const row = [
@@ -3955,6 +4162,15 @@ class ExportService {
const isGroup = sessionId.includes('@chatroom') const isGroup = sessionId.includes('@chatroom')
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid) const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({ onProgress?.({
current: 0, current: 0,
@@ -3967,13 +4183,31 @@ class ExportService {
await this.ensureVoiceModel(onProgress) await this.ensureVoiceModel(onProgress)
} }
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
// 如果没有消息,不创建文件 // 如果没有消息,不创建文件
if (collected.rows.length === 0) { if (collected.rows.length === 0) {
return { success: false, error: '该会话在指定时间范围内没有消息' } return { success: false, error: '该会话在指定时间范围内没有消息' }
} }
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
const groupNicknameCandidates = isGroup
? this.buildGroupNicknameIdCandidates([
...Array.from(senderUsernames.values()),
...collected.rows.map(msg => msg.senderUsername),
cleanedMyWxid
])
: []
const groupNicknamesMap = isGroup
? await this.getGroupNicknamesForRoom(sessionId, groupNicknameCandidates)
: new Map<string, string>()
if (isGroup) { if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true) await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
} }
@@ -4179,13 +4413,38 @@ class ExportService {
const timeText = this.formatTimestamp(msg.createTime) const timeText = this.formatTimestamp(msg.createTime)
const typeName = this.getMessageTypeName(msg.localType) const typeName = this.getMessageTypeName(msg.localType)
let textContent = this.formatHtmlMessageText(msg.content, msg.localType) let textContent = this.formatHtmlMessageText(
msg.content,
msg.localType,
cleanedMyWxid,
msg.senderUsername,
msg.isSend
)
if (msg.localType === 34 && useVoiceTranscript) { if (msg.localType === 34 && useVoiceTranscript) {
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]' textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} }
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) { if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
textContent = '' textContent = ''
} }
if (this.isTransferExportContent(textContent) && msg.content) {
const transferDesc = await this.resolveTransferDesc(
msg.content,
cleanedMyWxid,
groupNicknamesMap,
async (username) => {
const c = await getContactCached(username)
if (c.success && c.contact) {
return c.contact.remark || c.contact.nickName || c.contact.alias || username
}
return username
}
)
if (transferDesc) {
textContent = this.appendTransferDesc(textContent, transferDesc)
}
}
const linkCard = this.extractHtmlLinkCard(msg.content, msg.localType)
let mediaHtml = '' let mediaHtml = ''
if (mediaItem?.kind === 'image') { if (mediaItem?.kind === 'image') {
@@ -4201,9 +4460,11 @@ class ExportService {
mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>` mediaHtml = `<video class="message-media video" controls preload="metadata"${posterAttr} src="${this.escapeAttribute(encodeURI(mediaItem.relativePath))}"></video>`
} }
const textHtml = textContent const textHtml = linkCard
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>` ? `<div class="message-text"><a class="message-link-card" href="${this.escapeAttribute(linkCard.url)}" target="_blank" rel="noopener noreferrer">${this.renderTextWithEmoji(linkCard.title).replace(/\r?\n/g, '<br />')}</a></div>`
: '' : (textContent
? `<div class="message-text">${this.renderTextWithEmoji(textContent).replace(/\r?\n/g, '<br />')}</div>`
: '')
const senderNameHtml = isGroup const senderNameHtml = isGroup
? `<div class="sender-name">${this.escapeHtml(senderName)}</div>` ? `<div class="sender-name">${this.escapeHtml(senderName)}</div>`
: '' : ''
@@ -4349,6 +4610,12 @@ class ExportService {
</html>`); </html>`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
stream.on('error', (err) => {
// 确保在流错误时销毁流,释放文件句柄
stream.destroy()
reject(err)
})
stream.end(() => { stream.end(() => {
onProgress?.({ onProgress?.({
current: 100, current: 100,
@@ -4394,7 +4661,7 @@ class ExportService {
for (const sessionId of sessionIds) { for (const sessionId of sessionIds) {
const sessionInfo = await this.getContactInfo(sessionId) const sessionInfo = await this.getContactInfo(sessionId)
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange) const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange, options.senderUsername)
const msgs = collected.rows const msgs = collected.rows
const voiceMsgs = msgs.filter(m => m.localType === 34) const voiceMsgs = msgs.filter(m => m.localType === 34)
const mediaMsgs = msgs.filter(m => { const mediaMsgs = msgs.filter(m => {
@@ -4493,7 +4760,10 @@ class ExportService {
phase: 'exporting' phase: 'exporting'
}) })
const safeName = sessionInfo.displayName.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '') const sanitizeName = (value: string) => value.replace(/[<>:"\/\\|?*]/g, '_').replace(/\.+$/, '').trim()
const baseName = sanitizeName(sessionInfo.displayName || sessionId) || sanitizeName(sessionId) || 'session'
const suffix = sanitizeName(options.fileNameSuffix || '')
const safeName = suffix ? `${baseName}_${suffix}` : baseName
const useSessionFolder = sessionLayout === 'per-session' const useSessionFolder = sessionLayout === 'per-session'
const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir const sessionDir = useSessionFolder ? path.join(outputDir, safeName) : outputDir

View File

@@ -4,6 +4,7 @@ import ExcelJS from 'exceljs'
import { ConfigService } from './config' import { ConfigService } from './config'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { chatService } from './chatService' import { chatService } from './chatService'
import type { Message } from './chatService'
export interface GroupChatInfo { export interface GroupChatInfo {
username: string username: string
@@ -339,6 +340,92 @@ class GroupAnalyticsService {
return `${year}-${month}-${day} ${hour}:${minute}:${second}` return `${year}-${month}-${day} ${hour}:${minute}:${second}`
} }
private formatUnixTime(createTime: number): string {
if (!Number.isFinite(createTime) || createTime <= 0) return ''
const milliseconds = createTime > 1e12 ? createTime : createTime * 1000
const date = new Date(milliseconds)
if (Number.isNaN(date.getTime())) return String(createTime)
return this.formatDateTime(date)
}
private getSimpleMessageTypeName(localType: number): string {
const typeMap: Record<number, string> = {
1: '文本',
3: '图片',
34: '语音',
42: '名片',
43: '视频',
47: '表情',
48: '位置',
49: '链接/文件',
50: '通话',
10000: '系统',
266287972401: '拍一拍',
8594229559345: '红包',
8589934592049: '转账'
}
return typeMap[localType] || `类型(${localType})`
}
private normalizeIdCandidates(values: Array<string | null | undefined>): string[] {
return this.buildIdCandidates(values).map(value => value.toLowerCase())
}
private isSameAccountIdentity(left: string | null | undefined, right: string | null | undefined): boolean {
const leftCandidates = this.normalizeIdCandidates([left])
const rightCandidates = this.normalizeIdCandidates([right])
if (leftCandidates.length === 0 || rightCandidates.length === 0) return false
const rightSet = new Set(rightCandidates)
for (const leftCandidate of leftCandidates) {
if (rightSet.has(leftCandidate)) return true
for (const rightCandidate of rightCandidates) {
if (leftCandidate.startsWith(`${rightCandidate}_`) || rightCandidate.startsWith(`${leftCandidate}_`)) {
return true
}
}
}
return false
}
private resolveExportMessageContent(message: Message): string {
const parsed = String(message.parsedContent || '').trim()
if (parsed) return parsed
const raw = String(message.rawContent || '').trim()
if (raw) return raw
return ''
}
private async collectMessagesByMember(
chatroomId: string,
memberUsername: string,
startTime: number,
endTime: number
): Promise<{ success: boolean; data?: Message[]; error?: string }> {
const batchSize = 500
const matchedMessages: Message[] = []
let offset = 0
while (true) {
const batch = await chatService.getMessages(chatroomId, offset, batchSize, startTime, endTime, true)
if (!batch.success || !batch.messages) {
return { success: false, error: batch.error || '获取群消息失败' }
}
for (const message of batch.messages) {
if (this.isSameAccountIdentity(memberUsername, message.senderUsername)) {
matchedMessages.push(message)
}
}
const fetchedCount = batch.messages.length
if (fetchedCount <= 0 || !batch.hasMore) break
offset += fetchedCount
}
return { success: true, data: matchedMessages }
}
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()
@@ -611,6 +698,181 @@ class GroupAnalyticsService {
} }
} }
async exportGroupMemberMessages(
chatroomId: string,
memberUsername: string,
outputPath: string,
startTime?: number,
endTime?: number
): Promise<{ success: boolean; count?: number; error?: string }> {
try {
const conn = await this.ensureConnected()
if (!conn.success) return { success: false, error: conn.error }
const normalizedChatroomId = String(chatroomId || '').trim()
const normalizedMemberUsername = String(memberUsername || '').trim()
if (!normalizedChatroomId) return { success: false, error: '群聊ID不能为空' }
if (!normalizedMemberUsername) return { success: false, error: '成员ID不能为空' }
const beginTimestamp = Number.isFinite(startTime) && typeof startTime === 'number'
? Math.max(0, Math.floor(startTime))
: 0
const endTimestampValue = Number.isFinite(endTime) && typeof endTime === 'number'
? Math.max(0, Math.floor(endTime))
: 0
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([normalizedChatroomId, normalizedMemberUsername])
const groupName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[normalizedChatroomId] || normalizedChatroomId)
: normalizedChatroomId
const defaultMemberDisplayName = groupDisplay.success && groupDisplay.map
? (groupDisplay.map[normalizedMemberUsername] || normalizedMemberUsername)
: normalizedMemberUsername
let memberDisplayName = defaultMemberDisplayName
let memberAlias = ''
let memberRemark = ''
let memberGroupNickname = ''
const membersResult = await this.getGroupMembers(normalizedChatroomId)
if (membersResult.success && membersResult.data) {
const matchedMember = membersResult.data.find((item) =>
this.isSameAccountIdentity(item.username, normalizedMemberUsername)
)
if (matchedMember) {
memberDisplayName = matchedMember.displayName || defaultMemberDisplayName
memberAlias = matchedMember.alias || ''
memberRemark = matchedMember.remark || ''
memberGroupNickname = matchedMember.groupNickname || ''
}
}
const collected = await this.collectMessagesByMember(
normalizedChatroomId,
normalizedMemberUsername,
beginTimestamp,
endTimestampValue
)
if (!collected.success || !collected.data) {
return { success: false, error: collected.error || '获取成员消息失败' }
}
const records = collected.data.map((message, index) => ({
index: index + 1,
time: this.formatUnixTime(message.createTime),
sender: message.senderUsername || '',
messageType: this.getSimpleMessageTypeName(message.localType),
content: this.resolveExportMessageContent(message)
}))
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
const ext = path.extname(outputPath).toLowerCase()
if (ext === '.csv') {
const infoTitleRow = ['会话信息']
const infoRow = ['群聊ID', normalizedChatroomId, '', '群聊名称', groupName, '成员wxid', normalizedMemberUsername, '']
const memberRow = ['成员显示名', memberDisplayName, '成员备注', memberRemark, '群昵称', memberGroupNickname, '微信号', memberAlias]
const metaRow = ['导出工具', exportGenerator, '导出版本', exportVersion, '平台', exportPlatform, '导出时间', exportTime]
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
const csvRows: string[][] = [infoTitleRow, infoRow, memberRow, metaRow, header]
for (const record of records) {
csvRows.push([String(record.index), record.time, record.sender, record.messageType, record.content])
}
const csvLines = csvRows.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 worksheet = workbook.addWorksheet(this.sanitizeWorksheetName('成员消息记录'))
worksheet.getCell(1, 1).value = '会话信息'
worksheet.getCell(1, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getRow(1).height = 24
worksheet.getCell(2, 1).value = '群聊ID'
worksheet.getCell(2, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(2, 2, 2, 3)
worksheet.getCell(2, 2).value = normalizedChatroomId
worksheet.getCell(2, 4).value = '群聊名称'
worksheet.getCell(2, 4).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(2, 5).value = groupName
worksheet.getCell(2, 6).value = '成员wxid'
worksheet.getCell(2, 6).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.mergeCells(2, 7, 2, 8)
worksheet.getCell(2, 7).value = normalizedMemberUsername
worksheet.getCell(3, 1).value = '成员显示名'
worksheet.getCell(3, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 2).value = memberDisplayName
worksheet.getCell(3, 3).value = '成员备注'
worksheet.getCell(3, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 4).value = memberRemark
worksheet.getCell(3, 5).value = '群昵称'
worksheet.getCell(3, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 6).value = memberGroupNickname
worksheet.getCell(3, 7).value = '微信号'
worksheet.getCell(3, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(3, 8).value = memberAlias
worksheet.getCell(4, 1).value = '导出工具'
worksheet.getCell(4, 1).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 2).value = exportGenerator
worksheet.getCell(4, 3).value = '导出版本'
worksheet.getCell(4, 3).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 4).value = exportVersion
worksheet.getCell(4, 5).value = '平台'
worksheet.getCell(4, 5).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 6).value = exportPlatform
worksheet.getCell(4, 7).value = '导出时间'
worksheet.getCell(4, 7).font = { name: 'Calibri', bold: true, size: 11 }
worksheet.getCell(4, 8).value = exportTime
const headerRow = worksheet.getRow(5)
const header = ['序号', '时间', '发送者wxid', '消息类型', '内容']
header.forEach((title, index) => {
const cell = headerRow.getCell(index + 1)
cell.value = title
cell.font = { name: 'Calibri', bold: true, size: 11 }
})
headerRow.height = 22
worksheet.getColumn(1).width = 10
worksheet.getColumn(2).width = 22
worksheet.getColumn(3).width = 30
worksheet.getColumn(4).width = 16
worksheet.getColumn(5).width = 90
worksheet.getColumn(6).width = 16
worksheet.getColumn(7).width = 20
worksheet.getColumn(8).width = 24
let currentRow = 6
for (const record of records) {
const row = worksheet.getRow(currentRow)
row.getCell(1).value = record.index
row.getCell(2).value = record.time
row.getCell(3).value = record.sender
row.getCell(4).value = record.messageType
row.getCell(5).value = record.content
row.alignment = { vertical: 'top', wrapText: true }
currentRow += 1
}
await workbook.xlsx.writeFile(outputPath)
}
return { success: true, count: records.length }
} catch (e) {
return { success: false, error: String(e) }
}
}
async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> { async exportGroupMembers(chatroomId: string, outputPath: string): Promise<{ success: boolean; count?: number; error?: string }> {
try { try {
const conn = await this.ensureConnected() const conn = await this.ensureConnected()

View File

@@ -1,12 +1,15 @@
/** /**
* HTTP API 服务 * HTTP API 服务
* 提供 ChatLab 标准化格式的消息查询 API * 提供 ChatLab 标准化格式的消息查询 API
*/ */
import * as http from 'http' import * as http from 'http'
import * as fs from 'fs'
import * as path from 'path'
import { URL } from 'url' import { URL } from 'url'
import { chatService, Message } from './chatService' import { chatService, Message } from './chatService'
import { wcdbService } from './wcdbService' import { wcdbService } from './wcdbService'
import { ConfigService } from './config' import { ConfigService } from './config'
import { videoService } from './videoService'
// ChatLab 格式定义 // ChatLab 格式定义
interface ChatLabHeader { interface ChatLabHeader {
@@ -42,6 +45,7 @@ interface ChatLabMessage {
content: string | null content: string | null
platformMessageId?: string platformMessageId?: string
replyToMessageId?: string replyToMessageId?: string
mediaPath?: string
} }
interface ChatLabData { interface ChatLabData {
@@ -51,6 +55,22 @@ interface ChatLabData {
messages: ChatLabMessage[] messages: ChatLabMessage[]
} }
interface ApiMediaOptions {
enabled: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
}
type MediaKind = 'image' | 'voice' | 'video' | 'emoji'
interface ApiExportedMedia {
kind: MediaKind
fileName: string
fullPath: string
}
// ChatLab 消息类型映射 // ChatLab 消息类型映射
const ChatLabType = { const ChatLabType = {
TEXT: 0, TEXT: 0,
@@ -80,6 +100,7 @@ class HttpService {
private port: number = 5031 private port: number = 5031
private running: boolean = false private running: boolean = false
private connections: Set<import('net').Socket> = new Set() private connections: Set<import('net').Socket> = new Set()
private connectionMutex: boolean = false
constructor() { constructor() {
this.configService = ConfigService.getInstance() this.configService = ConfigService.getInstance()
@@ -100,9 +121,20 @@ class HttpService {
// 跟踪所有连接,以便关闭时能强制断开 // 跟踪所有连接,以便关闭时能强制断开
this.server.on('connection', (socket) => { this.server.on('connection', (socket) => {
this.connections.add(socket) // 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.add(socket)
this.connectionMutex = false
}
socket.on('close', () => { socket.on('close', () => {
this.connections.delete(socket) // 使用互斥锁防止并发修改
if (!this.connectionMutex) {
this.connectionMutex = true
this.connections.delete(socket)
this.connectionMutex = false
}
}) })
}) })
@@ -130,11 +162,20 @@ class HttpService {
async stop(): Promise<void> { async stop(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.server) { if (this.server) {
// 强制关闭所有活动连接 // 使用互斥锁保护连接集合操作
for (const socket of this.connections) { this.connectionMutex = true
socket.destroy() const socketsToClose = Array.from(this.connections)
}
this.connections.clear() this.connections.clear()
this.connectionMutex = false
// 强制关闭所有活动连接
for (const socket of socketsToClose) {
try {
socket.destroy()
} catch (err) {
console.error('[HttpService] Error destroying socket:', err)
}
}
this.server.close(() => { this.server.close(() => {
this.running = false this.running = false
@@ -163,6 +204,10 @@ class HttpService {
return this.port return this.port
} }
getDefaultMediaExportPath(): string {
return this.getApiMediaExportPath()
}
/** /**
* 处理 HTTP 请求 * 处理 HTTP 请求
*/ */
@@ -213,7 +258,7 @@ class HttpService {
ascending: boolean ascending: boolean
): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> { ): Promise<{ success: boolean; messages?: Message[]; hasMore?: boolean; error?: string }> {
try { try {
// 使用固定 batch 大小(与 limit 相同或最 500来减少循环次数 // 使用固定 batch 大小(与 limit 相同或最 500来减少循环次数
const batchSize = Math.min(limit, 500) const batchSize = Math.min(limit, 500)
const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime const beginTimestamp = startTime > 10000000000 ? Math.floor(startTime / 1000) : startTime
const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime const endTimestamp = endTime > 10000000000 ? Math.floor(endTime / 1000) : endTime
@@ -240,7 +285,7 @@ class HttpService {
let rows = batch.rows let rows = batch.rows
hasMore = batch.hasMore === true hasMore = batch.hasMore === true
// 处理 offset: 跳过前 N 条 // 处理 offset跳过前 N 条
if (skipped < offset) { if (skipped < offset) {
const remaining = offset - skipped const remaining = offset - skipped
if (remaining >= rows.length) { if (remaining >= rows.length) {
@@ -256,7 +301,7 @@ class HttpService {
const trimmedRows = allRows.slice(0, limit) const trimmedRows = allRows.slice(0, limit)
const finalHasMore = hasMore || allRows.length > limit const finalHasMore = hasMore || allRows.length > limit
const messages = this.mapRowsToMessagesSimple(trimmedRows) const messages = chatService.mapRowsToMessagesForApi(trimmedRows)
return { success: true, messages, hasMore: finalHasMore } return { success: true, messages, hasMore: finalHasMore }
} finally { } finally {
await wcdbService.closeMessageCursor(cursor) await wcdbService.closeMessageCursor(cursor)
@@ -268,145 +313,125 @@ class HttpService {
} }
/** /**
* 简单的行数据到 Message 映射(用于 API 输出) * Query param helpers.
*/ */
private mapRowsToMessagesSimple(rows: Record<string, any>[]): Message[] { private parseIntParam(value: string | null, defaultValue: number, min: number, max: number): number {
const myWxid = this.configService.get('myWxid') || '' const parsed = parseInt(value || '', 10)
const messages: Message[] = [] if (!Number.isFinite(parsed)) return defaultValue
return Math.min(Math.max(parsed, min), max)
for (const row of rows) {
const content = this.getField(row, ['message_content', 'messageContent', 'content', 'msg_content', 'WCDB_CT_message_content']) || ''
const localType = parseInt(this.getField(row, ['local_type', 'localType', 'type', 'msg_type', 'WCDB_CT_local_type']) || '1', 10)
const isSendRaw = this.getField(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'])
const senderUsername = this.getField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || ''
const createTime = parseInt(this.getField(row, ['create_time', 'createTime', 'msg_create_time', 'WCDB_CT_create_time']) || '0', 10)
const localId = parseInt(this.getField(row, ['local_id', 'localId', 'WCDB_CT_local_id', 'rowid']) || '0', 10)
const serverId = this.getField(row, ['server_id', 'serverId', 'WCDB_CT_server_id']) || ''
let isSend: number
if (isSendRaw !== null && isSendRaw !== undefined) {
isSend = parseInt(isSendRaw, 10)
} else if (senderUsername && myWxid) {
isSend = senderUsername.toLowerCase() === myWxid.toLowerCase() ? 1 : 0
} else {
isSend = 0
}
// 解析消息内容中的特殊字段
let parsedContent = content
let xmlType: string | undefined
let linkTitle: string | undefined
let fileName: string | undefined
let emojiCdnUrl: string | undefined
let emojiMd5: string | undefined
let imageMd5: string | undefined
let videoMd5: string | undefined
let cardNickname: string | undefined
if (localType === 49 && content) {
// 提取 type 子标签
const typeMatch = /<type>(\d+)<\/type>/i.exec(content)
if (typeMatch) xmlType = typeMatch[1]
// 提取 title
const titleMatch = /<title>([^<]*)<\/title>/i.exec(content)
if (titleMatch) linkTitle = titleMatch[1]
// 提取文件名
const fnMatch = /<title>([^<]*)<\/title>/i.exec(content)
if (fnMatch) fileName = fnMatch[1]
}
if (localType === 47 && content) {
const cdnMatch = /cdnurl\s*=\s*"([^"]+)"/i.exec(content)
if (cdnMatch) emojiCdnUrl = cdnMatch[1]
const md5Match = /md5\s*=\s*"([^"]+)"/i.exec(content)
if (md5Match) emojiMd5 = md5Match[1]
}
messages.push({
localId,
talker: '',
localType,
createTime,
sortSeq: createTime,
content: parsedContent,
isSend,
senderUsername,
serverId: serverId ? parseInt(serverId, 10) || 0 : 0,
rawContent: content,
parsedContent: content,
emojiCdnUrl,
emojiMd5,
imageMd5,
videoMd5,
xmlType,
linkTitle,
fileName,
cardNickname
} as Message)
}
return messages
} }
/** private parseBooleanParam(url: URL, keys: string[], defaultValue: boolean = false): boolean {
* 从行数据中获取字段值(兼容多种字段名)
*/
private getField(row: Record<string, any>, keys: string[]): string | null {
for (const key of keys) { for (const key of keys) {
if (row[key] !== undefined && row[key] !== null) { const raw = url.searchParams.get(key)
return String(row[key]) if (raw === null) continue
} const normalized = raw.trim().toLowerCase()
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
if (['0', 'false', 'no', 'off'].includes(normalized)) return false
}
return defaultValue
}
private parseMediaOptions(url: URL): ApiMediaOptions {
const mediaEnabled = this.parseBooleanParam(url, ['media', 'meiti'], false)
if (!mediaEnabled) {
return {
enabled: false,
exportImages: false,
exportVoices: false,
exportVideos: false,
exportEmojis: false
}
}
return {
enabled: true,
exportImages: this.parseBooleanParam(url, ['image', 'tupian'], true),
exportVoices: this.parseBooleanParam(url, ['voice', 'vioce'], true),
exportVideos: this.parseBooleanParam(url, ['video'], true),
exportEmojis: this.parseBooleanParam(url, ['emoji'], true)
} }
return null
} }
/**
* 处理消息查询
* GET /api/v1/messages?talker=xxx&limit=100&start=20260101&chatlab=1
*/
private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> { private async handleMessages(url: URL, res: http.ServerResponse): Promise<void> {
const talker = url.searchParams.get('talker') const talker = (url.searchParams.get('talker') || '').trim()
const limit = Math.min(parseInt(url.searchParams.get('limit') || '100', 10), 10000) const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
const offset = parseInt(url.searchParams.get('offset') || '0', 10) const offset = this.parseIntParam(url.searchParams.get('offset'), 0, 0, Number.MAX_SAFE_INTEGER)
const keyword = (url.searchParams.get('keyword') || '').trim().toLowerCase()
const startParam = url.searchParams.get('start') const startParam = url.searchParams.get('start')
const endParam = url.searchParams.get('end') const endParam = url.searchParams.get('end')
const chatlab = url.searchParams.get('chatlab') === '1' const chatlab = this.parseBooleanParam(url, ['chatlab'], false)
const formatParam = url.searchParams.get('format') const formatParam = (url.searchParams.get('format') || '').trim().toLowerCase()
const format = formatParam || (chatlab ? 'chatlab' : 'json') const format = formatParam || (chatlab ? 'chatlab' : 'json')
const mediaOptions = this.parseMediaOptions(url)
if (!talker) { if (!talker) {
this.sendError(res, 400, 'Missing required parameter: talker') this.sendError(res, 400, 'Missing required parameter: talker')
return return
} }
// 解析时间参数 (支持 YYYYMMDD 格式) if (format !== 'json' && format !== 'chatlab') {
this.sendError(res, 400, 'Invalid format, supported: json/chatlab')
return
}
const startTime = this.parseTimeParam(startParam) const startTime = this.parseTimeParam(startParam)
const endTime = this.parseTimeParam(endParam, true) const endTime = this.parseTimeParam(endParam, true)
const queryOffset = keyword ? 0 : offset
const queryLimit = keyword ? 10000 : limit
// 使用批量获取方法,绕过 chatService 的单 batch 限制 const result = await this.fetchMessagesBatch(talker, queryOffset, queryLimit, startTime, endTime, true)
const result = await this.fetchMessagesBatch(talker, offset, limit, startTime, endTime, true)
if (!result.success || !result.messages) { if (!result.success || !result.messages) {
this.sendError(res, 500, result.error || 'Failed to get messages') this.sendError(res, 500, result.error || 'Failed to get messages')
return return
} }
if (format === 'chatlab') { let messages = result.messages
// 获取会话显示名 let hasMore = result.hasMore === true
const displayNames = await this.getDisplayNames([talker])
const talkerName = displayNames[talker] || talker
const chatLabData = await this.convertToChatLab(result.messages, talker, talkerName) if (keyword) {
this.sendJson(res, chatLabData) const filtered = messages.filter((msg) => {
} else { const content = (msg.parsedContent || msg.rawContent || '').toLowerCase()
// 返回原始消息格式 return content.includes(keyword)
this.sendJson(res, {
success: true,
talker,
count: result.messages.length,
hasMore: result.hasMore,
messages: result.messages
}) })
const endIndex = offset + limit
hasMore = filtered.length > endIndex
messages = filtered.slice(offset, endIndex)
} }
const mediaMap = mediaOptions.enabled
? await this.exportMediaForMessages(messages, talker, mediaOptions)
: new Map<number, ApiExportedMedia>()
const displayNames = await this.getDisplayNames([talker])
const talkerName = displayNames[talker] || talker
if (format === 'chatlab') {
const chatLabData = await this.convertToChatLab(messages, talker, talkerName, mediaMap)
this.sendJson(res, {
...chatLabData,
media: {
enabled: mediaOptions.enabled,
exportPath: this.getApiMediaExportPath(),
count: mediaMap.size
}
})
return
}
const apiMessages = messages.map((msg) => this.toApiMessage(msg, mediaMap.get(msg.localId)))
this.sendJson(res, {
success: true,
talker,
count: apiMessages.length,
hasMore,
media: {
enabled: mediaOptions.enabled,
exportPath: this.getApiMediaExportPath(),
count: mediaMap.size
},
messages: apiMessages
})
} }
/** /**
@@ -414,8 +439,8 @@ class HttpService {
* GET /api/v1/sessions?keyword=xxx&limit=100 * GET /api/v1/sessions?keyword=xxx&limit=100
*/ */
private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> { private async handleSessions(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = url.searchParams.get('keyword') || '' const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = parseInt(url.searchParams.get('limit') || '100', 10) const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try { try {
const sessions = await chatService.getSessions() const sessions = await chatService.getSessions()
@@ -457,8 +482,8 @@ class HttpService {
* GET /api/v1/contacts?keyword=xxx&limit=100 * GET /api/v1/contacts?keyword=xxx&limit=100
*/ */
private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> { private async handleContacts(url: URL, res: http.ServerResponse): Promise<void> {
const keyword = url.searchParams.get('keyword') || '' const keyword = (url.searchParams.get('keyword') || '').trim()
const limit = parseInt(url.searchParams.get('limit') || '100', 10) const limit = this.parseIntParam(url.searchParams.get('limit'), 100, 1, 10000)
try { try {
const contacts = await chatService.getContacts() const contacts = await chatService.getContacts()
@@ -490,6 +515,156 @@ class HttpService {
} }
} }
private getApiMediaExportPath(): string {
return path.join(this.configService.getCacheBasePath(), 'api-media')
}
private sanitizeFileName(value: string, fallback: string): string {
const safe = (value || '')
.trim()
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
.replace(/\.+$/g, '')
return safe || fallback
}
private ensureDir(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true })
}
}
private detectImageExt(buffer: Buffer): string {
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg'
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return '.png'
if (buffer.length >= 6) {
const sig6 = buffer.subarray(0, 6).toString('ascii')
if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return '.gif'
}
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return '.webp'
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d) return '.bmp'
return '.jpg'
}
private async exportMediaForMessages(
messages: Message[],
talker: string,
options: ApiMediaOptions
): Promise<Map<number, ApiExportedMedia>> {
const mediaMap = new Map<number, ApiExportedMedia>()
if (!options.enabled || messages.length === 0) {
return mediaMap
}
const sessionDir = path.join(this.getApiMediaExportPath(), this.sanitizeFileName(talker, 'session'))
this.ensureDir(sessionDir)
for (const msg of messages) {
const exported = await this.exportMediaForMessage(msg, talker, sessionDir, options)
if (exported) {
mediaMap.set(msg.localId, exported)
}
}
return mediaMap
}
private async exportMediaForMessage(
msg: Message,
talker: string,
sessionDir: string,
options: ApiMediaOptions
): Promise<ApiExportedMedia | null> {
try {
if (msg.localType === 3 && options.exportImages) {
const result = await chatService.getImageData(talker, String(msg.localId))
if (result.success && result.data) {
const imageBuffer = Buffer.from(result.data, 'base64')
const ext = this.detectImageExt(imageBuffer)
const fileBase = this.sanitizeFileName(msg.imageMd5 || msg.imageDatName || `image_${msg.localId}`, `image_${msg.localId}`)
const fileName = `${fileBase}${ext}`
const targetDir = path.join(sessionDir, 'images')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, imageBuffer)
}
return { kind: 'image', fileName, fullPath }
}
}
if (msg.localType === 34 && options.exportVoices) {
const result = await chatService.getVoiceData(
talker,
String(msg.localId),
msg.createTime || undefined,
msg.serverId || undefined
)
if (result.success && result.data) {
const fileName = `voice_${msg.localId}.wav`
const targetDir = path.join(sessionDir, 'voices')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.writeFileSync(fullPath, Buffer.from(result.data, 'base64'))
}
return { kind: 'voice', fileName, fullPath }
}
}
if (msg.localType === 43 && options.exportVideos && msg.videoMd5) {
const info = await videoService.getVideoInfo(msg.videoMd5)
if (info.exists && info.videoUrl && fs.existsSync(info.videoUrl)) {
const ext = path.extname(info.videoUrl) || '.mp4'
const fileName = `${this.sanitizeFileName(msg.videoMd5, `video_${msg.localId}`)}${ext}`
const targetDir = path.join(sessionDir, 'videos')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(info.videoUrl, fullPath)
}
return { kind: 'video', fileName, fullPath }
}
}
if (msg.localType === 47 && options.exportEmojis && msg.emojiCdnUrl) {
const result = await chatService.downloadEmoji(msg.emojiCdnUrl, msg.emojiMd5)
if (result.success && result.localPath && fs.existsSync(result.localPath)) {
const sourceExt = path.extname(result.localPath) || '.gif'
const fileName = `${this.sanitizeFileName(msg.emojiMd5 || `emoji_${msg.localId}`, `emoji_${msg.localId}`)}${sourceExt}`
const targetDir = path.join(sessionDir, 'emojis')
const fullPath = path.join(targetDir, fileName)
this.ensureDir(targetDir)
if (!fs.existsSync(fullPath)) {
fs.copyFileSync(result.localPath, fullPath)
}
return { kind: 'emoji', fileName, fullPath }
}
}
} catch (e) {
console.warn('[HttpService] exportMediaForMessage failed:', e)
}
return null
}
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record<string, any> {
return {
localId: msg.localId,
serverId: msg.serverId,
localType: msg.localType,
createTime: msg.createTime,
sortSeq: msg.sortSeq,
isSend: msg.isSend,
senderUsername: msg.senderUsername,
content: this.getMessageContent(msg),
rawContent: msg.rawContent,
parsedContent: msg.parsedContent,
mediaType: media?.kind,
mediaFileName: media?.fileName,
mediaPath: media?.fullPath
}
}
/** /**
* 解析时间参数 * 解析时间参数
* 支持 YYYYMMDD 格式,返回秒级时间戳 * 支持 YYYYMMDD 格式,返回秒级时间戳
@@ -497,7 +672,7 @@ class HttpService {
private parseTimeParam(param: string | null, isEnd: boolean = false): number { private parseTimeParam(param: string | null, isEnd: boolean = false): number {
if (!param) return 0 if (!param) return 0
// 纯数字且长度为8视为 YYYYMMDD // 纯数字且长度为 8视为 YYYYMMDD
if (/^\d{8}$/.test(param)) { if (/^\d{8}$/.test(param)) {
const year = parseInt(param.slice(0, 4), 10) const year = parseInt(param.slice(0, 4), 10)
const month = parseInt(param.slice(4, 6), 10) - 1 const month = parseInt(param.slice(4, 6), 10) - 1
@@ -539,7 +714,12 @@ class HttpService {
/** /**
* 转换为 ChatLab 格式 * 转换为 ChatLab 格式
*/ */
private async convertToChatLab(messages: Message[], talkerId: string, talkerName: string): Promise<ChatLabData> { private async convertToChatLab(
messages: Message[],
talkerId: string,
talkerName: string,
mediaMap: Map<number, ApiExportedMedia> = new Map()
): Promise<ChatLabData> {
const isGroup = talkerId.endsWith('@chatroom') const isGroup = talkerId.endsWith('@chatroom')
const myWxid = this.configService.get('myWxid') || '' const myWxid = this.configService.get('myWxid') || ''
@@ -603,7 +783,8 @@ class HttpService {
timestamp: msg.createTime, timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg), type: this.mapMessageType(msg.localType, msg),
content: this.getMessageContent(msg), content: this.getMessageContent(msg),
platformMessageId: msg.serverId ? String(msg.serverId) : undefined platformMessageId: msg.serverId ? String(msg.serverId) : undefined,
mediaPath: mediaMap.get(msg.localId)?.fullPath
} }
}) })
@@ -705,13 +886,13 @@ class HttpService {
case 1: case 1:
return msg.rawContent || null return msg.rawContent || null
case 3: case 3:
return msg.imageMd5 || '[图片]' return '[图片]'
case 34: case 34:
return '[语音]' return '[语音]'
case 43: case 43:
return msg.videoMd5 || '[视频]' return '[视频]'
case 47: case 47:
return msg.emojiCdnUrl || msg.emojiMd5 || '[表情]' return '[表情]'
case 42: case 42:
return msg.cardNickname || '[名片]' return msg.cardNickname || '[名片]'
case 48: case 48:
@@ -743,3 +924,4 @@ class HttpService {
} }
export const httpService = new HttpService() export const httpService = new HttpService()

View File

@@ -45,6 +45,7 @@ type DecryptResult = {
localPath?: string localPath?: string
error?: string error?: string
isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图) isThumb?: boolean // 是否是缩略图(没有高清图时返回缩略图)
liveVideoPath?: string // 实况照片的视频路径
} }
type HardlinkState = { type HardlinkState = {
@@ -61,6 +62,7 @@ export class ImageDecryptService {
private cacheIndexed = false private cacheIndexed = false
private cacheIndexing: Promise<void> | null = null private cacheIndexing: Promise<void> | null = null
private updateFlags = new Map<string, boolean>() private updateFlags = new Map<string, boolean>()
private noLiveSet = new Set<string>() // 已确认无 live 视频的图片路径
private logInfo(message: string, meta?: Record<string, unknown>): void { private logInfo(message: string, meta?: Record<string, unknown>): void {
if (!this.configService.get('logEnabled')) return if (!this.configService.get('logEnabled')) return
@@ -116,8 +118,9 @@ export class ImageDecryptService {
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached)) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(cached))
return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate } return { success: true, localPath: dataUrl || this.filePathToUrl(cached), hasUpdate, liveVideoPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(key) this.resolvedCache.delete(key)
@@ -136,8 +139,9 @@ export class ImageDecryptService {
} else { } else {
this.updateFlags.delete(key) this.updateFlags.delete(key)
} }
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing)) this.emitCacheResolved(payload, key, dataUrl || this.filePathToUrl(existing))
return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate } return { success: true, localPath: dataUrl || this.filePathToUrl(existing), hasUpdate, liveVideoPath }
} }
} }
this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName }) this.logInfo('未找到缓存', { md5: payload.imageMd5, datName: payload.imageDatName })
@@ -151,13 +155,25 @@ export class ImageDecryptService {
return { success: false, error: '缺少图片标识' } return { success: false, error: '缺少图片标识' }
} }
if (payload.force) {
const hdCached = this.findCachedOutput(cacheKey, true, payload.sessionId)
if (hdCached && existsSync(hdCached) && this.isImageFile(hdCached) && !this.isThumbnailPath(hdCached)) {
const dataUrl = this.fileToDataUrl(hdCached)
const localPath = dataUrl || this.filePathToUrl(hdCached)
const liveVideoPath = this.checkLiveVideoCache(hdCached)
this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb: false, liveVideoPath }
}
}
if (!payload.force) { if (!payload.force) {
const cached = this.resolvedCache.get(cacheKey) const cached = this.resolvedCache.get(cacheKey)
if (cached && existsSync(cached) && this.isImageFile(cached)) { if (cached && existsSync(cached) && this.isImageFile(cached)) {
const dataUrl = this.fileToDataUrl(cached) const dataUrl = this.fileToDataUrl(cached)
const localPath = dataUrl || this.filePathToUrl(cached) const localPath = dataUrl || this.filePathToUrl(cached)
const liveVideoPath = this.isThumbnailPath(cached) ? undefined : this.checkLiveVideoCache(cached)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath } return { success: true, localPath, liveVideoPath }
} }
if (cached && !this.isImageFile(cached)) { if (cached && !this.isImageFile(cached)) {
this.resolvedCache.delete(cacheKey) this.resolvedCache.delete(cacheKey)
@@ -235,8 +251,9 @@ export class ImageDecryptService {
const dataUrl = this.fileToDataUrl(existing) const dataUrl = this.fileToDataUrl(existing)
const localPath = dataUrl || this.filePathToUrl(existing) const localPath = dataUrl || this.filePathToUrl(existing)
const isThumb = this.isThumbnailPath(existing) const isThumb = this.isThumbnailPath(existing)
const liveVideoPath = isThumb ? undefined : this.checkLiveVideoCache(existing)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb } return { success: true, localPath, isThumb, liveVideoPath }
} }
} }
@@ -296,7 +313,15 @@ export class ImageDecryptService {
const dataUrl = this.bufferToDataUrl(decrypted, finalExt) const dataUrl = this.bufferToDataUrl(decrypted, finalExt)
const localPath = dataUrl || this.filePathToUrl(outputPath) const localPath = dataUrl || this.filePathToUrl(outputPath)
this.emitCacheResolved(payload, cacheKey, localPath) this.emitCacheResolved(payload, cacheKey, localPath)
return { success: true, localPath, isThumb }
// 检测实况照片Motion Photo
let liveVideoPath: string | undefined
if (!isThumb && (finalExt === '.jpg' || finalExt === '.jpeg')) {
const videoPath = await this.extractMotionPhotoVideo(outputPath, decrypted)
if (videoPath) liveVideoPath = this.filePathToUrl(videoPath)
}
return { success: true, localPath, isThumb, liveVideoPath }
} catch (e) { } catch (e) {
this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName }) this.logError('解密失败', e, { md5: payload.imageMd5, datName: payload.imageDatName })
return { success: false, error: String(e) } return { success: false, error: String(e) }
@@ -332,23 +357,37 @@ export class ImageDecryptService {
* 获取解密后的缓存目录(用于查找 hardlink.db * 获取解密后的缓存目录(用于查找 hardlink.db
*/ */
private getDecryptedCacheDir(wxid: string): string | null { private getDecryptedCacheDir(wxid: string): string | null {
const cachePath = this.configService.get('cachePath')
if (!cachePath) return null
const cleanedWxid = this.cleanAccountDirName(wxid) const cleanedWxid = this.cleanAccountDirName(wxid)
const cacheAccountDir = join(cachePath, cleanedWxid) const configured = this.configService.get('cachePath')
const documentsPath = app.getPath('documents')
const baseCandidates = Array.from(new Set([
configured || '',
join(documentsPath, 'WeFlow'),
join(documentsPath, 'WeFlowData'),
this.configService.getCacheBasePath()
].filter(Boolean)))
// 检查缓存目录下是否有 hardlink.db for (const base of baseCandidates) {
if (existsSync(join(cacheAccountDir, 'hardlink.db'))) { const accountCandidates = Array.from(new Set([
return cacheAccountDir join(base, wxid),
} join(base, cleanedWxid),
if (existsSync(join(cachePath, 'hardlink.db'))) { join(base, 'databases', wxid),
return cachePath join(base, 'databases', cleanedWxid)
} ]))
const cacheHardlinkDir = join(cacheAccountDir, 'db_storage', 'hardlink') for (const accountDir of accountCandidates) {
if (existsSync(join(cacheHardlinkDir, 'hardlink.db'))) { if (existsSync(join(accountDir, 'hardlink.db'))) {
return cacheHardlinkDir return accountDir
}
const hardlinkSubdir = join(accountDir, 'db_storage', 'hardlink')
if (existsSync(join(hardlinkSubdir, 'hardlink.db'))) {
return hardlinkSubdir
}
}
if (existsSync(join(base, 'hardlink.db'))) {
return base
}
} }
return null return null
} }
@@ -357,7 +396,8 @@ export class ImageDecryptService {
existsSync(join(dirPath, 'hardlink.db')) || existsSync(join(dirPath, 'hardlink.db')) ||
existsSync(join(dirPath, 'db_storage')) || existsSync(join(dirPath, 'db_storage')) ||
existsSync(join(dirPath, 'FileStorage', 'Image')) || existsSync(join(dirPath, 'FileStorage', 'Image')) ||
existsSync(join(dirPath, 'FileStorage', 'Image2')) existsSync(join(dirPath, 'FileStorage', 'Image2')) ||
existsSync(join(dirPath, 'msg', 'attach'))
) )
} }
@@ -423,6 +463,12 @@ export class ImageDecryptService {
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath) if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath return hdPath
} }
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
if (imageDatName) this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
// 没找到高清图,返回 null不进行全局搜索 // 没找到高清图,返回 null不进行全局搜索
return null return null
} }
@@ -440,9 +486,16 @@ export class ImageDecryptService {
// 找到缩略图但要求高清图,尝试同目录查找高清图变体 // 找到缩略图但要求高清图,尝试同目录查找高清图变体
const hdPath = this.findHdVariantInSameDir(fallbackPath) const hdPath = this.findHdVariantInSameDir(fallbackPath)
if (hdPath) { if (hdPath) {
this.cacheDatPath(accountDir, imageMd5, hdPath)
this.cacheDatPath(accountDir, imageDatName, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath return hdPath
} }
const hdInDir = await this.searchDatFileInDir(dirname(fallbackPath), imageDatName || imageMd5 || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageMd5, hdInDir)
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
@@ -465,15 +518,17 @@ export class ImageDecryptService {
this.cacheDatPath(accountDir, imageDatName, hdPath) this.cacheDatPath(accountDir, imageDatName, hdPath)
return hdPath return hdPath
} }
const hdInDir = await this.searchDatFileInDir(dirname(hardlinkPath), imageDatName || '', false)
if (hdInDir) {
this.cacheDatPath(accountDir, imageDatName, hdInDir)
return hdInDir
}
return null return null
} }
this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName }) this.logInfo('[ImageDecrypt] hardlink miss (datName)', { imageDatName })
} }
// 如果要求高清图但 hardlink 没找到,也不要搜索了(搜索太慢) // force 模式下也继续尝试缓存目录/文件系统搜索,避免 hardlink.db 缺行时只能拿到缩略图
if (!allowThumbnail) {
return null
}
if (!imageDatName) return null if (!imageDatName) return null
if (!skipResolvedCache) { if (!skipResolvedCache) {
@@ -483,6 +538,8 @@ export class ImageDecryptService {
// 缓存的是缩略图,尝试找高清图 // 缓存的是缩略图,尝试找高清图
const hdPath = this.findHdVariantInSameDir(cached) const hdPath = this.findHdVariantInSameDir(cached)
if (hdPath) return hdPath if (hdPath) return hdPath
const hdInDir = await this.searchDatFileInDir(dirname(cached), imageDatName, false)
if (hdInDir) return hdInDir
} }
} }
@@ -1681,6 +1738,76 @@ export class ImageDecryptService {
return mostCommonKey return mostCommonKey
} }
/**
* 检查图片对应的 live 视频缓存,返回 file:// URL 或 undefined
* 已确认无 live 的路径会被记录,下次直接跳过
*/
private checkLiveVideoCache(imagePath: string): string | undefined {
if (this.noLiveSet.has(imagePath)) return undefined
const livePath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
if (existsSync(livePath)) return this.filePathToUrl(livePath)
this.noLiveSet.add(imagePath)
return undefined
}
/**
* 检测并分离 Motion Photo实况照片
* Google Motion Photo = JPEG + MP4 拼接在一起
* 返回视频文件路径,如果不是实况照片则返回 null
*/
private async extractMotionPhotoVideo(imagePath: string, imageBuffer: Buffer): Promise<string | null> {
// 只处理 JPEG 文件
if (imageBuffer.length < 8) return null
if (imageBuffer[0] !== 0xff || imageBuffer[1] !== 0xd8) return null
// 从末尾向前搜索 MP4 ftyp 原子签名
// ftyp 原子结构: [4字节大小][ftyp(66 74 79 70)][品牌...]
// 实际起始位置在 ftyp 前4字节大小字段
const ftypSig = [0x66, 0x74, 0x79, 0x70] // 'ftyp'
let videoOffset: number | null = null
const searchEnd = Math.max(0, imageBuffer.length - 8)
for (let i = searchEnd; i > 0; i--) {
if (imageBuffer[i] === ftypSig[0] &&
imageBuffer[i + 1] === ftypSig[1] &&
imageBuffer[i + 2] === ftypSig[2] &&
imageBuffer[i + 3] === ftypSig[3]) {
// ftyp 前4字节是 box size实际 MP4 从这里开始
videoOffset = i - 4
break
}
}
// 备用:从 XMP 元数据中读取偏移量
if (videoOffset === null || videoOffset <= 0) {
try {
const text = imageBuffer.toString('latin1')
const match = text.match(/MediaDataOffset="(\d+)"/i) || text.match(/MicroVideoOffset="(\d+)"/i)
if (match) {
const offset = parseInt(match[1], 10)
if (offset > 0 && offset < imageBuffer.length) {
videoOffset = imageBuffer.length - offset
}
}
} catch { }
}
if (videoOffset === null || videoOffset <= 100) return null
// 验证视频部分确实以有效 MP4 数据开头
const videoStart = imageBuffer[videoOffset + 4] === 0x66 &&
imageBuffer[videoOffset + 5] === 0x74 &&
imageBuffer[videoOffset + 6] === 0x79 &&
imageBuffer[videoOffset + 7] === 0x70
if (!videoStart) return null
// 写出视频文件
const videoPath = imagePath.replace(/\.(jpg|jpeg|png)$/i, '_live.mp4')
const videoBuffer = imageBuffer.slice(videoOffset)
await writeFile(videoPath, videoBuffer)
return videoPath
}
/** /**
* 解包 wxgf 格式 * 解包 wxgf 格式
* wxgf 是微信的图片格式,内部使用 HEVC 编码 * wxgf 是微信的图片格式,内部使用 HEVC 编码

View File

@@ -1,371 +0,0 @@
import fs from "fs";
import { app, BrowserWindow } from "electron";
import path from "path";
import { ConfigService } from './config';
// Define interfaces locally to avoid static import of types that might not be available or cause issues
type LlamaModel = any;
type LlamaContext = any;
type LlamaChatSession = any;
export class LlamaService {
private _model: LlamaModel | null = null;
private _context: LlamaContext | null = null;
private _sequence: any = null;
private _session: LlamaChatSession | null = null;
private _llama: any = null;
private _nodeLlamaCpp: any = null;
private configService = new ConfigService();
private _initialized = false;
constructor() {
// 延迟初始化,只在需要时初始化
}
public async init() {
if (this._initialized) return;
try {
// Dynamic import to handle ESM module in CJS context
this._nodeLlamaCpp = await import("node-llama-cpp");
this._llama = await this._nodeLlamaCpp.getLlama();
this._initialized = true;
console.log("[LlamaService] Llama initialized");
} catch (error) {
console.error("[LlamaService] Failed to initialize Llama:", error);
}
}
public async loadModel(modelPath: string) {
if (!this._llama) await this.init();
try {
console.log("[LlamaService] Loading model from:", modelPath);
if (!this._llama) {
throw new Error("Llama not initialized");
}
this._model = await this._llama.loadModel({
modelPath: modelPath,
gpuLayers: 'max', // Offload all layers to GPU if possible
useMlock: false // Disable mlock to avoid "VirtualLock" errors (common on Windows)
});
if (!this._model) throw new Error("Failed to load model");
this._context = await this._model.createContext({
contextSize: 8192, // Balanced context size for better performance
batchSize: 2048 // Increase batch size for better prompt processing speed
});
if (!this._context) throw new Error("Failed to create context");
this._sequence = this._context.getSequence();
const { LlamaChatSession } = this._nodeLlamaCpp;
this._session = new LlamaChatSession({
contextSequence: this._sequence
});
console.log("[LlamaService] Model loaded successfully");
return true;
} catch (error) {
console.error("[LlamaService] Failed to load model:", error);
throw error;
}
}
public async createSession(systemPrompt?: string) {
if (!this._context) throw new Error("Model not loaded");
if (!this._nodeLlamaCpp) await this.init();
const { LlamaChatSession } = this._nodeLlamaCpp;
if (!this._sequence) {
this._sequence = this._context.getSequence();
}
this._session = new LlamaChatSession({
contextSequence: this._sequence,
systemPrompt: systemPrompt
});
return true;
}
public async chat(message: string, options: { thinking?: boolean } = {}, onToken: (token: string) => void) {
if (!this._session) throw new Error("Session not initialized");
const thinking = options.thinking ?? false;
// Sampling parameters based on mode
const samplingParams = thinking ? {
temperature: 0.6,
topP: 0.95,
topK: 20,
repeatPenalty: 1.5 // PresencePenalty=1.5
} : {
temperature: 0.7,
topP: 0.8,
topK: 20,
repeatPenalty: 1.5
};
try {
const response = await this._session.prompt(message, {
...samplingParams,
onTextChunk: (chunk: string) => {
onToken(chunk);
}
});
return response;
} catch (error) {
console.error("[LlamaService] Chat error:", error);
throw error;
}
}
public async getModelStatus(modelPath: string) {
try {
const exists = fs.existsSync(modelPath);
if (!exists) {
return { exists: false, path: modelPath };
}
const stats = fs.statSync(modelPath);
return {
exists: true,
path: modelPath,
size: stats.size
};
} catch (error) {
return { exists: false, error: String(error) };
}
}
private resolveModelDir(): string {
const configured = this.configService.get('whisperModelDir') as string | undefined;
if (configured) return configured;
return path.join(app.getPath('documents'), 'WeFlow', 'models');
}
public async downloadModel(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
// Ensure directory exists
const dir = path.dirname(savePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
console.info(`[LlamaService] Multi-threaded download check for: ${savePath}`);
if (fs.existsSync(savePath)) {
fs.unlinkSync(savePath);
}
// 1. Get total size and check range support
let probeResult;
try {
probeResult = await this.probeUrl(url);
} catch (err) {
console.warn("[LlamaService] Probe failed, falling back to single-thread.", err);
return this.downloadSingleThread(url, savePath, onProgress);
}
const { totalSize, acceptRanges, finalUrl } = probeResult;
console.log(`[LlamaService] Total size: ${totalSize}, Accept-Ranges: ${acceptRanges}`);
if (totalSize <= 0 || !acceptRanges) {
console.warn("[LlamaService] Ranges not supported or size unknown, falling back to single-thread.");
return this.downloadSingleThread(finalUrl, savePath, onProgress);
}
const threadCount = 4;
const chunkSize = Math.ceil(totalSize / threadCount);
const fd = fs.openSync(savePath, 'w');
let downloadedLength = 0;
let lastDownloadedLength = 0;
let lastTime = Date.now();
let speed = 0;
const speedInterval = setInterval(() => {
const now = Date.now();
const duration = (now - lastTime) / 1000;
if (duration > 0) {
speed = (downloadedLength - lastDownloadedLength) / duration;
lastDownloadedLength = downloadedLength;
lastTime = now;
onProgress({ downloaded: downloadedLength, total: totalSize, speed });
}
}, 1000);
try {
const promises = [];
for (let i = 0; i < threadCount; i++) {
const start = i * chunkSize;
const end = i === threadCount - 1 ? totalSize - 1 : (i + 1) * chunkSize - 1;
promises.push(this.downloadChunk(finalUrl, fd, start, end, (bytes) => {
downloadedLength += bytes;
}));
}
await Promise.all(promises);
console.log("[LlamaService] Multi-threaded download complete");
// Final progress update
onProgress({ downloaded: totalSize, total: totalSize, speed: 0 });
} catch (err) {
console.error("[LlamaService] Multi-threaded download failed:", err);
throw err;
} finally {
clearInterval(speedInterval);
fs.closeSync(fd);
}
}
private async probeUrl(url: string): Promise<{ totalSize: number, acceptRanges: boolean, finalUrl: string }> {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/',
'Range': 'bytes=0-0'
}
};
return new Promise((resolve, reject) => {
const req = protocol.get(url, options, (res: any) => {
if ([301, 302, 307, 308].includes(res.statusCode)) {
const location = res.headers.location;
const nextUrl = new URL(location, url).href;
this.probeUrl(nextUrl).then(resolve).catch(reject);
return;
}
if (res.statusCode !== 206 && res.statusCode !== 200) {
reject(new Error(`Probe failed: HTTP ${res.statusCode}`));
return;
}
const contentRange = res.headers['content-range'];
let totalSize = 0;
if (contentRange) {
const parts = contentRange.split('/');
totalSize = parseInt(parts[parts.length - 1], 10);
} else {
totalSize = parseInt(res.headers['content-length'] || '0', 10);
}
const acceptRanges = res.headers['accept-ranges'] === 'bytes' || !!contentRange;
resolve({ totalSize, acceptRanges, finalUrl: url });
res.destroy();
});
req.on('error', reject);
});
}
private async downloadChunk(url: string, fd: number, start: number, end: number, onData: (bytes: number) => void): Promise<void> {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/',
'Range': `bytes=${start}-${end}`
}
};
return new Promise((resolve, reject) => {
const req = protocol.get(url, options, (res: any) => {
if (res.statusCode !== 206) {
reject(new Error(`Chunk download failed: HTTP ${res.statusCode}`));
return;
}
let currentOffset = start;
res.on('data', (chunk: Buffer) => {
try {
fs.writeSync(fd, chunk, 0, chunk.length, currentOffset);
currentOffset += chunk.length;
onData(chunk.length);
} catch (err) {
reject(err);
res.destroy();
}
});
res.on('end', () => resolve());
res.on('error', reject);
});
req.on('error', reject);
});
}
private async downloadSingleThread(url: string, savePath: string, onProgress: (payload: { downloaded: number; total: number; speed: number }) => void): Promise<void> {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? require('https') : require('http');
const options = {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': 'https://www.modelscope.cn/'
}
};
const request = protocol.get(url, options, (response: any) => {
if ([301, 302, 307, 308].includes(response.statusCode)) {
const location = response.headers.location;
const nextUrl = new URL(location, url).href;
this.downloadSingleThread(nextUrl, savePath, onProgress).then(resolve).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`Fallback download failed: HTTP ${response.statusCode}`));
return;
}
const totalLength = parseInt(response.headers['content-length'] || '0', 10);
let downloadedLength = 0;
let lastDownloadedLength = 0;
let lastTime = Date.now();
let speed = 0;
const fileStream = fs.createWriteStream(savePath);
response.pipe(fileStream);
const speedInterval = setInterval(() => {
const now = Date.now();
const duration = (now - lastTime) / 1000;
if (duration > 0) {
speed = (downloadedLength - lastDownloadedLength) / duration;
lastDownloadedLength = downloadedLength;
lastTime = now;
onProgress({ downloaded: downloadedLength, total: totalLength, speed });
}
}, 1000);
response.on('data', (chunk: any) => {
downloadedLength += chunk.length;
});
fileStream.on('finish', () => {
clearInterval(speedInterval);
fileStream.close();
resolve();
});
fileStream.on('error', (err: any) => {
clearInterval(speedInterval);
fs.unlink(savePath, () => { });
reject(err);
});
});
request.on('error', reject);
});
}
public getModelsPath() {
return this.resolveModelDir();
}
}
export const llamaService = new LlamaService();

View File

@@ -38,6 +38,8 @@ export interface SnsPost {
likes: string[] likes: string[]
comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[] comments: { id: string; nickname: string; content: string; refCommentId: string; refNickname?: string }[]
rawXml?: string rawXml?: string
linkTitle?: string
linkUrl?: string
} }
@@ -145,6 +147,18 @@ class SnsService {
return join(this.getSnsCacheDir(), `${hash}${ext}`) return join(this.getSnsCacheDir(), `${hash}${ext}`)
} }
// 获取所有发过朋友圈的用户名列表
async getSnsUsernames(): Promise<{ success: boolean; usernames?: string[]; error?: string }> {
const result = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT user_name FROM SnsTimeLine')
if (!result.success || !result.rows) {
// 尝试 userName 列名
const result2 = await wcdbService.execQuery('sns', null, 'SELECT DISTINCT userName FROM SnsTimeLine')
if (!result2.success || !result2.rows) return { success: false, error: result.error || result2.error }
return { success: true, usernames: result2.rows.map((r: any) => r.userName).filter(Boolean) }
}
return { success: true, usernames: result.rows.map((r: any) => r.user_name).filter(Boolean) }
}
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 }> {
const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime)
@@ -266,6 +280,367 @@ class SnsService {
return this.fetchAndDecryptImage(url, key) return this.fetchAndDecryptImage(url, key)
} }
/**
* 导出朋友圈动态
* 支持筛选条件(用户名、关键词)和媒体文件导出
*/
async exportTimeline(options: {
outputDir: string
format: 'json' | 'html'
usernames?: string[]
keyword?: string
exportMedia?: boolean
startTime?: number
endTime?: number
}, progressCallback?: (progress: { current: number; total: number; status: string }) => void): Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }> {
const { outputDir, format, usernames, keyword, exportMedia = false, startTime, endTime } = options
try {
// 确保输出目录存在
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true })
}
// 1. 分页加载全部帖子
const allPosts: SnsPost[] = []
const pageSize = 50
let endTs: number | undefined = endTime // 使用 endTime 作为分页起始上界
let hasMore = true
progressCallback?.({ current: 0, total: 0, status: '正在加载朋友圈数据...' })
while (hasMore) {
const result = await this.getTimeline(pageSize, 0, usernames, keyword, startTime, endTs)
if (result.success && result.timeline && result.timeline.length > 0) {
allPosts.push(...result.timeline)
// 下一页的 endTs 为当前最后一条帖子的时间 - 1
const lastTs = result.timeline[result.timeline.length - 1].createTime - 1
endTs = lastTs
hasMore = result.timeline.length >= pageSize
// 如果已经低于 startTime提前终止
if (startTime && lastTs < startTime) {
hasMore = false
}
progressCallback?.({ current: allPosts.length, total: 0, status: `已加载 ${allPosts.length} 条动态...` })
} else {
hasMore = false
}
}
if (allPosts.length === 0) {
return { success: true, filePath: '', postCount: 0, mediaCount: 0 }
}
progressCallback?.({ current: 0, total: allPosts.length, status: `${allPosts.length} 条动态,准备导出...` })
// 2. 如果需要导出媒体,创建 media 子目录并下载
let mediaCount = 0
const mediaDir = join(outputDir, 'media')
if (exportMedia) {
if (!existsSync(mediaDir)) {
mkdirSync(mediaDir, { recursive: true })
}
// 收集所有媒体下载任务
const mediaTasks: { media: SnsMedia; postId: string; mi: number }[] = []
for (const post of allPosts) {
post.media.forEach((media, mi) => mediaTasks.push({ media, postId: post.id, mi }))
}
// 并发下载5路
let done = 0
const concurrency = 5
const runTask = async (task: typeof mediaTasks[0]) => {
const { media, postId, mi } = task
try {
const isVideo = isVideoUrl(media.url)
const ext = isVideo ? 'mp4' : 'jpg'
const fileName = `${postId}_${mi}.${ext}`
const filePath = join(mediaDir, fileName)
if (existsSync(filePath)) {
;(media as any).localPath = `media/${fileName}`
mediaCount++
} else {
const result = await this.fetchAndDecryptImage(media.url, media.key)
if (result.success && result.data) {
await writeFile(filePath, result.data)
;(media as any).localPath = `media/${fileName}`
mediaCount++
} else if (result.success && result.cachePath) {
const cachedData = await readFile(result.cachePath)
await writeFile(filePath, cachedData)
;(media as any).localPath = `media/${fileName}`
mediaCount++
}
}
} catch (e) {
console.warn(`[SnsExport] 媒体下载失败: ${task.media.url}`, e)
}
done++
progressCallback?.({ current: done, total: mediaTasks.length, status: `正在下载媒体 (${done}/${mediaTasks.length})...` })
}
// 控制并发的执行器
const queue = [...mediaTasks]
const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
while (queue.length > 0) {
const task = queue.shift()!
await runTask(task)
}
})
await Promise.all(workers)
}
// 2.5 下载头像
const avatarMap = new Map<string, string>()
if (format === 'html') {
if (!existsSync(mediaDir)) mkdirSync(mediaDir, { recursive: true })
const uniqueUsers = [...new Map(allPosts.filter(p => p.avatarUrl).map(p => [p.username, p])).values()]
let avatarDone = 0
const avatarQueue = [...uniqueUsers]
const avatarWorkers = Array.from({ length: Math.min(5, avatarQueue.length) }, async () => {
while (avatarQueue.length > 0) {
const post = avatarQueue.shift()!
try {
const fileName = `avatar_${crypto.createHash('md5').update(post.username).digest('hex').slice(0, 8)}.jpg`
const filePath = join(mediaDir, fileName)
if (existsSync(filePath)) {
avatarMap.set(post.username, `media/${fileName}`)
} else {
const result = await this.fetchAndDecryptImage(post.avatarUrl!)
if (result.success && result.data) {
await writeFile(filePath, result.data)
avatarMap.set(post.username, `media/${fileName}`)
}
}
} catch (e) { /* 头像下载失败不影响导出 */ }
avatarDone++
progressCallback?.({ current: avatarDone, total: uniqueUsers.length, status: `正在下载头像 (${avatarDone}/${uniqueUsers.length})...` })
}
})
await Promise.all(avatarWorkers)
}
// 3. 生成输出文件
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
let outputFilePath: string
if (format === 'json') {
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.json`)
const exportData = {
exportTime: new Date().toISOString(),
totalPosts: allPosts.length,
filters: {
usernames: usernames || [],
keyword: keyword || ''
},
posts: allPosts.map(p => ({
id: p.id,
username: p.username,
nickname: p.nickname,
createTime: p.createTime,
createTimeStr: new Date(p.createTime * 1000).toLocaleString('zh-CN'),
contentDesc: p.contentDesc,
type: p.type,
media: p.media.map(m => ({
url: m.url,
thumb: m.thumb,
localPath: (m as any).localPath || undefined
})),
likes: p.likes,
comments: p.comments,
linkTitle: (p as any).linkTitle,
linkUrl: (p as any).linkUrl
}))
}
await writeFile(outputFilePath, JSON.stringify(exportData, null, 2), 'utf-8')
} else {
// HTML 格式
outputFilePath = join(outputDir, `朋友圈导出_${timestamp}.html`)
const html = this.generateHtml(allPosts, { usernames, keyword }, avatarMap)
await writeFile(outputFilePath, html, 'utf-8')
}
progressCallback?.({ current: allPosts.length, total: allPosts.length, status: '导出完成!' })
return { success: true, filePath: outputFilePath, postCount: allPosts.length, mediaCount }
} catch (e: any) {
console.error('[SnsExport] 导出失败:', e)
return { success: false, error: e.message || String(e) }
}
}
/**
* 生成朋友圈 HTML 导出文件
*/
private generateHtml(posts: SnsPost[], filters: { usernames?: string[]; keyword?: string }, avatarMap?: Map<string, string>): string {
const escapeHtml = (str: string) => str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/\n/g, '<br>')
const formatTime = (ts: number) => {
const d = new Date(ts * 1000)
const now = new Date()
const isCurrentYear = d.getFullYear() === now.getFullYear()
const pad = (n: number) => String(n).padStart(2, '0')
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`
const m = d.getMonth() + 1, day = d.getDate()
return isCurrentYear ? `${m}${day}${timeStr}` : `${d.getFullYear()}${m}${day}${timeStr}`
}
// 生成头像首字母
const avatarLetter = (name: string) => {
const ch = name.charAt(0)
return escapeHtml(ch || '?')
}
let filterInfo = ''
if (filters.keyword) filterInfo += `关键词: "${escapeHtml(filters.keyword)}" `
if (filters.usernames && filters.usernames.length > 0) filterInfo += `筛选用户: ${filters.usernames.length}`
const postsHtml = posts.map(post => {
const mediaCount = post.media.length
const gridClass = mediaCount === 1 ? 'grid-1' : mediaCount === 2 || mediaCount === 4 ? 'grid-2' : 'grid-3'
const mediaHtml = post.media.map((m, mi) => {
const localPath = (m as any).localPath
if (localPath) {
if (isVideoUrl(m.url)) {
return `<div class="mi"><video src="${escapeHtml(localPath)}" controls preload="metadata"></video></div>`
}
return `<div class="mi"><img src="${escapeHtml(localPath)}" loading="lazy" onclick="openLb(this.src)" alt=""></div>`
}
return `<div class="mi ml"><a href="${escapeHtml(m.url)}" target="_blank">查看媒体</a></div>`
}).join('')
const linkHtml = post.linkTitle && post.linkUrl
? `<a class="lk" href="${escapeHtml(post.linkUrl)}" target="_blank"><span class="lk-t">${escapeHtml(post.linkTitle)}</span><span class="lk-a"></span></a>`
: ''
const likesHtml = post.likes.length > 0
? `<div class="interactions"><div class="likes">♥ ${post.likes.map(l => `<span>${escapeHtml(l)}</span>`).join('、')}</div></div>`
: ''
const commentsHtml = post.comments.length > 0
? `<div class="interactions${post.likes.length > 0 ? ' cmt-border' : ''}"><div class="cmts">${post.comments.map(c => {
const ref = c.refNickname ? `<span class="re">回复</span><b>${escapeHtml(c.refNickname)}</b>` : ''
return `<div class="cmt"><b>${escapeHtml(c.nickname)}</b>${ref}${escapeHtml(c.content)}</div>`
}).join('')}</div></div>`
: ''
const avatarSrc = avatarMap?.get(post.username)
const avatarHtml = avatarSrc
? `<div class="avatar"><img src="${escapeHtml(avatarSrc)}" alt=""></div>`
: `<div class="avatar">${avatarLetter(post.nickname)}</div>`
return `<div class="post">
${avatarHtml}
<div class="body">
<div class="hd"><span class="nick">${escapeHtml(post.nickname)}</span><span class="tm">${formatTime(post.createTime)}</span></div>
${post.contentDesc ? `<div class="txt">${escapeHtml(post.contentDesc)}</div>` : ''}
${mediaHtml ? `<div class="mg ${gridClass}">${mediaHtml}</div>` : ''}
${linkHtml}
${likesHtml}
${commentsHtml}
</div></div>`
}).join('\n')
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>朋友圈导出</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--t1);line-height:1.6;-webkit-font-smoothing:antialiased}
:root{--bg:#F0EEE9;--card:rgba(255,255,255,.92);--t1:#3d3d3d;--t2:#666;--t3:#999;--accent:#8B7355;--border:rgba(0,0,0,.08);--bg3:rgba(0,0,0,.03)}
@media(prefers-color-scheme:dark){:root{--bg:#1a1a1a;--card:rgba(40,40,40,.85);--t1:#e0e0e0;--t2:#aaa;--t3:#777;--accent:#c4a882;--border:rgba(255,255,255,.1);--bg3:rgba(255,255,255,.06)}}
.container{max-width:800px;margin:0 auto;padding:20px 24px 60px}
/* 页面标题 */
.feed-hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;padding:0 4px}
.feed-hd h2{font-size:20px;font-weight:700}
.feed-hd .info{font-size:12px;color:var(--t3)}
/* 帖子卡片 - 头像+内容双列 */
.post{background:var(--card);border-radius:16px;border:1px solid var(--border);padding:20px;margin-bottom:24px;display:flex;gap:16px;box-shadow:0 2px 8px rgba(0,0,0,.02);transition:transform .2s,box-shadow .2s}
.post:hover{transform:translateY(-2px);box-shadow:0 8px 16px rgba(0,0,0,.06)}
.avatar{width:48px;height:48px;border-radius:12px;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:600;flex-shrink:0;overflow:hidden}
.avatar img{width:100%;height:100%;object-fit:cover}
.body{flex:1;min-width:0}
.hd{display:flex;flex-direction:column;margin-bottom:8px}
.nick{font-size:15px;font-weight:700;color:var(--accent);margin-bottom:2px}
.tm{font-size:12px;color:var(--t3)}
.txt{font-size:15px;line-height:1.6;white-space:pre-wrap;word-break:break-word;margin-bottom:12px}
/* 媒体网格 */
.mg{display:grid;gap:6px;margin-bottom:12px;max-width:320px}
.grid-1{max-width:300px}
.grid-1 .mi{border-radius:12px}
.grid-1 .mi img{aspect-ratio:auto;max-height:480px;object-fit:contain;background:var(--bg3)}
.grid-2{grid-template-columns:1fr 1fr}
.grid-3{grid-template-columns:1fr 1fr 1fr}
.mi{overflow:hidden;border-radius:12px;background:var(--bg3);position:relative;aspect-ratio:1}
.mi img{width:100%;height:100%;object-fit:cover;display:block;cursor:zoom-in;transition:opacity .2s}
.mi img:hover{opacity:.9}
.mi video{width:100%;height:100%;object-fit:cover;display:block;background:#000}
.ml{display:flex;align-items:center;justify-content:center}
.ml a{color:var(--accent);text-decoration:none;font-size:13px}
/* 链接卡片 */
.lk{display:flex;align-items:center;gap:10px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:12px;text-decoration:none;color:var(--t1);font-size:14px;margin-bottom:12px;transition:background .15s}
.lk:hover{background:var(--border)}
.lk-t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.lk-a{color:var(--t3);font-size:18px;flex-shrink:0}
/* 互动区域 */
.interactions{margin-top:12px;padding-top:12px;border-top:1px dashed var(--border);font-size:13px}
.interactions.cmt-border{border-top:none;padding-top:0;margin-top:8px}
.likes{color:var(--accent);font-weight:500;line-height:1.8}
.cmts{background:var(--bg3);border-radius:8px;padding:8px 12px;line-height:1.4}
.cmt{margin-bottom:4px;color:var(--t2)}
.cmt:last-child{margin-bottom:0}
.cmt b{color:var(--accent);font-weight:500}
.re{color:var(--t3);margin:0 4px;font-size:12px}
/* 灯箱 */
.lb{display:none;position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:9999;align-items:center;justify-content:center;cursor:zoom-out}
.lb.on{display:flex}
.lb img{max-width:92vw;max-height:92vh;object-fit:contain;border-radius:4px}
/* 回到顶部 */
.btt{position:fixed;right:24px;bottom:32px;width:44px;height:44px;border-radius:50%;background:var(--card);box-shadow:0 2px 12px rgba(0,0,0,.12);border:1px solid var(--border);cursor:pointer;font-size:18px;display:none;align-items:center;justify-content:center;z-index:100;color:var(--t2)}
.btt:hover{transform:scale(1.1)}
.btt.show{display:flex}
/* 页脚 */
.ft{text-align:center;padding:32px 0 24px;font-size:12px;color:var(--t3)}
</style>
</head>
<body>
<div class="container">
<div class="feed-hd"><h2>朋友圈</h2><span class="info">共 ${posts.length}${filterInfo ? ` · ${filterInfo}` : ''}</span></div>
${postsHtml}
<div class="ft">由 WeFlow 导出 · ${new Date().toLocaleString('zh-CN')}</div>
</div>
<div class="lb" id="lb" onclick="closeLb()"><img id="lbi" src=""></div>
<button class="btt" id="btt" onclick="scrollTo({top:0,behavior:'smooth'})">↑</button>
<script>
function openLb(s){document.getElementById('lbi').src=s;document.getElementById('lb').classList.add('on');document.body.style.overflow='hidden'}
function closeLb(){document.getElementById('lb').classList.remove('on');document.body.style.overflow=''}
document.addEventListener('keydown',function(e){if(e.key==='Escape')closeLb()})
window.addEventListener('scroll',function(){document.getElementById('btt').classList.toggle('show',window.scrollY>600)})
</script>
</body>
</html>`
}
private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> { private async fetchAndDecryptImage(url: string, key?: string | number): Promise<{ success: boolean; data?: Buffer; contentType?: string; cachePath?: string; error?: string }> {
if (!url) return { success: false, error: 'url 不能为空' } if (!url) return { success: false, error: 'url 不能为空' }
@@ -321,7 +696,6 @@ class SnsService {
} }
res.pipe(fileStream) res.pipe(fileStream)
fileStream.on('finish', async () => { fileStream.on('finish', async () => {
fileStream.close() fileStream.close()
@@ -381,6 +755,12 @@ class SnsService {
resolve({ success: false, error: e.message }) resolve({ success: false, error: e.message })
}) })
req.setTimeout(15000, () => {
req.destroy()
fs.unlink(tmpPath, () => { })
resolve({ success: false, error: '请求超时' })
})
req.end() req.end()
} catch (e: any) { } catch (e: any) {
@@ -467,6 +847,10 @@ class SnsService {
}) })
req.on('error', (e: any) => resolve({ success: false, error: e.message })) req.on('error', (e: any) => resolve({ success: false, error: e.message }))
req.setTimeout(15000, () => {
req.destroy()
resolve({ success: false, error: '请求超时' })
})
req.end() req.end()
} catch (e: any) { } catch (e: any) {
resolve({ success: false, error: e.message }) resolve({ success: false, error: e.message })

View File

@@ -458,8 +458,18 @@ export class VoiceTranscribeService {
writer.on('error', (err) => { writer.on('error', (err) => {
clearInterval(speedInterval) clearInterval(speedInterval)
// 确保在错误情况下也关闭文件句柄
writer.destroy()
reject(err) reject(err)
}) })
response.on('error', (err) => {
clearInterval(speedInterval)
// 确保在响应错误时也关闭文件句柄
writer.destroy()
reject(err)
})
response.pipe(writer) response.pipe(writer)
}) })
request.on('error', reject) request.on('error', reject)

View File

@@ -66,8 +66,12 @@ export class WcdbCore {
private wcdbVerifyUser: any = null private wcdbVerifyUser: any = null
private wcdbStartMonitorPipe: any = null private wcdbStartMonitorPipe: any = null
private wcdbStopMonitorPipe: any = null private wcdbStopMonitorPipe: any = null
private wcdbGetMonitorPipeName: any = null
private monitorPipeClient: any = null private monitorPipeClient: any = null
private monitorCallback: ((type: string, json: string) => void) | null = null
private monitorReconnectTimer: any = null
private monitorPipePath: string = ''
private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map() private avatarUrlCache: Map<string, { url?: string; updatedAt: number }> = new Map()
@@ -92,63 +96,94 @@ export class WcdbCore {
// 使用命名管道 IPC // 使用命名管道 IPC
startMonitor(callback: (type: string, json: string) => void): boolean { startMonitor(callback: (type: string, json: string) => void): boolean {
if (!this.wcdbStartMonitorPipe) { if (!this.wcdbStartMonitorPipe) {
this.writeLog('startMonitor: wcdbStartMonitorPipe not available')
return false return false
} }
this.monitorCallback = callback
try { try {
const result = this.wcdbStartMonitorPipe() const result = this.wcdbStartMonitorPipe()
if (result !== 0) { if (result !== 0) {
this.writeLog(`startMonitor: wcdbStartMonitorPipe failed with ${result}`)
return false return false
} }
const net = require('net') // 从 DLL 获取动态管道名(含 PID
const PIPE_PATH = '\\\\.\\pipe\\weflow_monitor' let pipePath = '\\\\.\\pipe\\weflow_monitor'
if (this.wcdbGetMonitorPipeName) {
setTimeout(() => { try {
this.monitorPipeClient = net.createConnection(PIPE_PATH, () => { const namePtr = [null as any]
this.writeLog('Monitor pipe connected') if (this.wcdbGetMonitorPipeName(namePtr) === 0 && namePtr[0]) {
}) pipePath = this.koffi.decode(namePtr[0], 'char', -1)
this.wcdbFreeString(namePtr[0])
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)
}
}
} }
}) } catch {}
}
this.monitorPipeClient.on('error', (err: Error) => { this.connectMonitorPipe(pipePath)
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 return true
} catch (e) { } catch (e) {
console.error('打开数据库异常:', e) console.error('[wcdbCore] startMonitor exception:', e)
return false return false
} }
} }
// 连接命名管道,支持断开后自动重连
private connectMonitorPipe(pipePath: string) {
this.monitorPipePath = pipePath
const net = require('net')
setTimeout(() => {
if (!this.monitorCallback) return
this.monitorPipeClient = net.createConnection(this.monitorPipePath, () => {
})
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)
this.monitorCallback?.(parsed.action || 'update', line)
} catch {
this.monitorCallback?.('update', line)
}
}
}
})
this.monitorPipeClient.on('error', () => {
})
this.monitorPipeClient.on('close', () => {
this.monitorPipeClient = null
this.scheduleReconnect()
})
}, 100)
}
// 定时重连
private scheduleReconnect() {
if (this.monitorReconnectTimer || !this.monitorCallback) return
this.monitorReconnectTimer = setTimeout(() => {
this.monitorReconnectTimer = null
if (this.monitorCallback && !this.monitorPipeClient) {
this.connectMonitorPipe(this.monitorPipePath)
}
}, 3000)
}
stopMonitor(): void { stopMonitor(): void {
this.monitorCallback = null
if (this.monitorReconnectTimer) {
clearTimeout(this.monitorReconnectTimer)
this.monitorReconnectTimer = null
}
if (this.monitorPipeClient) { if (this.monitorPipeClient) {
this.monitorPipeClient.destroy() this.monitorPipeClient.destroy()
this.monitorPipeClient = null this.monitorPipeClient = null
@@ -569,11 +604,13 @@ export class WcdbCore {
try { try {
this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()') this.wcdbStartMonitorPipe = this.lib.func('int32 wcdb_start_monitor_pipe()')
this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()') this.wcdbStopMonitorPipe = this.lib.func('void wcdb_stop_monitor_pipe()')
this.wcdbGetMonitorPipeName = this.lib.func('int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)')
this.writeLog('Monitor pipe functions loaded') this.writeLog('Monitor pipe functions loaded')
} catch (e) { } catch (e) {
console.warn('Failed to load monitor pipe functions:', e) console.warn('Failed to load monitor pipe functions:', e)
this.wcdbStartMonitorPipe = null this.wcdbStartMonitorPipe = null
this.wcdbStopMonitorPipe = null this.wcdbStopMonitorPipe = null
this.wcdbGetMonitorPipeName = null
} }
// void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len)
@@ -987,7 +1024,7 @@ export class WcdbCore {
} }
try { try {
// 1. 打开游标 (使用 Ascending=1 从指定时间往后查) // 1. 打开游标 (使用 Ascending=1 从指定时间往后查)
const openRes = await this.openMessageCursorLite(sessionId, limit, true, minTime, 0) const openRes = await this.openMessageCursor(sessionId, limit, true, minTime, 0)
if (!openRes.success || !openRes.cursor) { if (!openRes.success || !openRes.cursor) {
return { success: false, error: openRes.error } return { success: false, error: openRes.error }
} }
@@ -1583,12 +1620,20 @@ export class WcdbCore {
} }
} }
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, params: any[] = []): 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: '接口未就绪' } if (!this.wcdbExecQuery) return { success: false, error: '接口未就绪' }
// 如果提供了参数,使用参数化查询(需要 C++ 层支持)
// 注意:当前 wcdbExecQuery 可能不支持参数化,这是一个占位符实现
// TODO: 需要更新 C++ 层的 wcdb_exec_query 以支持参数绑定
if (params && params.length > 0) {
console.warn('[wcdbCore] execQuery: 参数化查询暂未在 C++ 层实现,将使用原始 SQL可能存在注入风险')
}
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]) {

View File

@@ -136,7 +136,6 @@ export class WcdbService {
*/ */
setMonitor(callback: (type: string, json: string) => void): void { setMonitor(callback: (type: string, json: string) => void): void {
this.monitorListener = callback; this.monitorListener = callback;
// Notify worker to enable monitor
this.callWorker('setMonitor').catch(() => { }); this.callWorker('setMonitor').catch(() => { });
} }
@@ -362,10 +361,10 @@ export class WcdbService {
} }
/** /**
* 执行 SQL 查询 * 执行 SQL 查询(支持参数化查询)
*/ */
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, params: any[] = []): Promise<{ success: boolean; rows?: any[]; error?: string }> {
return this.callWorker('execQuery', { kind, path, sql }) return this.callWorker('execQuery', { kind, path, sql, params })
} }
/** /**

114
electron/utils/LRUCache.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* LRU (Least Recently Used) Cache implementation for memory management
*/
export class LRUCache<K, V> {
private cache: Map<K, V>
private maxSize: number
constructor(maxSize: number = 100) {
this.maxSize = maxSize
this.cache = new Map()
}
/**
* Get value from cache
*/
get(key: K): V | undefined {
const value = this.cache.get(key)
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key)
this.cache.set(key, value)
}
return value
}
/**
* Set value in cache
*/
set(key: K, value: V): void {
if (this.cache.has(key)) {
// Update existing
this.cache.delete(key)
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value
if (firstKey !== undefined) {
this.cache.delete(firstKey)
}
}
this.cache.set(key, value)
}
/**
* Check if key exists
*/
has(key: K): boolean {
return this.cache.has(key)
}
/**
* Delete key from cache
*/
delete(key: K): boolean {
return this.cache.delete(key)
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear()
}
/**
* Get current cache size
*/
get size(): number {
return this.cache.size
}
/**
* Get all keys (for debugging)
*/
keys(): IterableIterator<K> {
return this.cache.keys()
}
/**
* Get all values (for debugging)
*/
values(): IterableIterator<V> {
return this.cache.values()
}
/**
* Get all entries (for iteration support)
*/
entries(): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Make LRUCache iterable (for...of support)
*/
[Symbol.iterator](): IterableIterator<[K, V]> {
return this.cache.entries()
}
/**
* Force cleanup (optional method for explicit memory management)
*/
cleanup(): void {
// In JavaScript/TypeScript, this is mainly for consistency
// The garbage collector will handle actual memory cleanup
if (this.cache.size > this.maxSize * 1.5) {
// Emergency cleanup if cache somehow exceeds limit
const entries = Array.from(this.cache.entries())
this.cache.clear()
// Keep only the most recent half
const keepEntries = entries.slice(-Math.floor(this.maxSize / 2))
keepEntries.forEach(([key, value]) => this.cache.set(key, value))
}
}
}

View File

@@ -118,7 +118,7 @@ if (parentPort) {
result = await core.closeMessageCursor(payload.cursor) result = await core.closeMessageCursor(payload.cursor)
break break
case 'execQuery': case 'execQuery':
result = await core.execQuery(payload.kind, payload.path, payload.sql) result = await core.execQuery(payload.kind, payload.path, payload.sql, payload.params)
break break
case 'getEmoticonCdnUrl': case 'getEmoticonCdnUrl':
result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5) result = await core.getEmoticonCdnUrl(payload.dbPath, payload.md5)

1921
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "weflow", "name": "weflow",
"version": "2.0.1", "version": "2.1.0",
"description": "WeFlow", "description": "WeFlow",
"main": "dist-electron/main.js", "main": "dist-electron/main.js",
"author": "cc", "author": "cc",
@@ -32,7 +32,6 @@
"jszip": "^3.10.1", "jszip": "^3.10.1",
"koffi": "^2.9.0", "koffi": "^2.9.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"node-llama-cpp": "^3.15.1",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

Binary file not shown.

View File

@@ -22,10 +22,9 @@ 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 NotificationWindow from './pages/NotificationWindow'
import AIChatPage from './pages/AIChatPage'
import { useAppStore } from './stores/appStore' import { useAppStore } from './stores/appStore'
import { themes, useThemeStore, type ThemeId } from './stores/themeStore' import { themes, useThemeStore, type ThemeId, type ThemeMode } from './stores/themeStore'
import * as configService from './services/config' import * as configService from './services/config'
import { Download, X, Shield } from 'lucide-react' import { Download, X, Shield } from 'lucide-react'
import './App.scss' import './App.scss'
@@ -35,6 +34,7 @@ import UpdateProgressCapsule from './components/UpdateProgressCapsule'
import LockScreen from './components/LockScreen' import LockScreen from './components/LockScreen'
import { GlobalSessionMonitor } from './components/GlobalSessionMonitor' import { GlobalSessionMonitor } from './components/GlobalSessionMonitor'
import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal' import { BatchTranscribeGlobal } from './components/BatchTranscribeGlobal'
import { BatchImageDecryptGlobal } from './components/BatchImageDecryptGlobal'
function App() { function App() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -101,14 +101,27 @@ function App() {
// 应用主题 // 应用主题
useEffect(() => { useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme) const mq = window.matchMedia('(prefers-color-scheme: dark)')
document.documentElement.setAttribute('data-mode', themeMode) const applyMode = (mode: ThemeMode, systemDark?: boolean) => {
const effectiveMode = mode === 'system' ? (systemDark ?? mq.matches ? 'dark' : 'light') : mode
// 更新窗口控件颜色以适配主题 document.documentElement.setAttribute('data-theme', currentTheme)
const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a' document.documentElement.setAttribute('data-mode', effectiveMode)
if (!isOnboardingWindow && !isNotificationWindow) { const symbolColor = effectiveMode === 'dark' ? '#ffffff' : '#1a1a1a'
window.electronAPI.window.setTitleBarOverlay({ symbolColor }) if (!isOnboardingWindow && !isNotificationWindow) {
window.electronAPI.window.setTitleBarOverlay({ symbolColor })
}
} }
applyMode(themeMode)
// 监听系统主题变化
const handler = (e: MediaQueryListEvent) => {
if (useThemeStore.getState().themeMode === 'system') {
applyMode('system', e.matches)
}
}
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow]) }, [currentTheme, themeMode, isOnboardingWindow, isNotificationWindow])
// 读取已保存的主题设置 // 读取已保存的主题设置
@@ -122,7 +135,7 @@ function App() {
if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) { if (savedThemeId && themes.some((theme) => theme.id === savedThemeId)) {
setTheme(savedThemeId as ThemeId) setTheme(savedThemeId as ThemeId)
} }
if (savedThemeMode === 'light' || savedThemeMode === 'dark') { if (savedThemeMode === 'light' || savedThemeMode === 'dark' || savedThemeMode === 'system') {
setThemeMode(savedThemeMode) setThemeMode(savedThemeMode)
} }
} catch (e) { } catch (e) {
@@ -182,10 +195,12 @@ function App() {
if (isNotificationWindow) return // Skip updates in notification window if (isNotificationWindow) return // Skip updates in notification window
const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => { const removeUpdateListener = window.electronAPI?.app?.onUpdateAvailable?.((info: any) => {
// 发现新版本时自动打开更新弹窗 // 发现新版本时保存更新信息,锁定状态下不弹窗,解锁后再显示
if (info) { if (info) {
setUpdateInfo({ ...info, hasUpdate: true }) setUpdateInfo({ ...info, hasUpdate: true })
setShowUpdateDialog(true) if (!useAppStore.getState().isLocked) {
setShowUpdateDialog(true)
}
} }
}) })
const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => { const removeProgressListener = window.electronAPI?.app?.onDownloadProgress?.((progress: any) => {
@@ -197,6 +212,13 @@ function App() {
} }
}, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow]) }, [setUpdateInfo, setDownloadProgress, setShowUpdateDialog, isNotificationWindow])
// 解锁后显示暂存的更新弹窗
useEffect(() => {
if (!isLocked && updateInfo?.hasUpdate && !showUpdateDialog && !isDownloading) {
setShowUpdateDialog(true)
}
}, [isLocked])
const handleUpdateNow = async () => { const handleUpdateNow = async () => {
setShowUpdateDialog(false) setShowUpdateDialog(false)
setIsDownloading(true) setIsDownloading(true)
@@ -291,7 +313,7 @@ function App() {
const checkLock = async () => { const checkLock = async () => {
// 并行获取配置,减少等待 // 并行获取配置,减少等待
const [enabled, useHello] = await Promise.all([ const [enabled, useHello] = await Promise.all([
configService.getAuthEnabled(), window.electronAPI.auth.verifyEnabled(),
configService.getAuthUseHello() configService.getAuthUseHello()
]) ])
@@ -364,6 +386,7 @@ function App() {
{/* 全局批量转写进度浮窗 */} {/* 全局批量转写进度浮窗 */}
<BatchTranscribeGlobal /> <BatchTranscribeGlobal />
<BatchImageDecryptGlobal />
{/* 用户协议弹窗 */} {/* 用户协议弹窗 */}
{showAgreement && !agreementLoading && ( {showAgreement && !agreementLoading && (
@@ -435,7 +458,7 @@ function App() {
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/home" element={<HomePage />} /> <Route path="/home" element={<HomePage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/ai-chat" element={<AIChatPage />} />
<Route path="/analytics" element={<AnalyticsWelcomePage />} /> <Route path="/analytics" element={<AnalyticsWelcomePage />} />
<Route path="/analytics/view" element={<AnalyticsPage />} /> <Route path="/analytics/view" element={<AnalyticsPage />} />
<Route path="/group-analytics" element={<GroupAnalyticsPage />} /> <Route path="/group-analytics" element={<GroupAnalyticsPage />} />

View File

@@ -0,0 +1,133 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
export const BatchImageDecryptGlobal: React.FC = () => {
const {
isBatchDecrypting,
progress,
showToast,
showResultToast,
result,
sessionName,
startTime,
setShowToast,
setShowResultToast
} = useBatchImageDecryptStore()
const voiceToastOccupied = useBatchTranscribeStore(
state => state.isBatchTranscribing && state.showToast
)
const [eta, setEta] = useState('')
useEffect(() => {
if (!isBatchDecrypting || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
if (elapsed <= 0) return
const rate = progress.current / elapsed
const remain = progress.total - progress.current
if (remain <= 0 || rate <= 0) {
setEta('')
return
}
const seconds = Math.ceil((remain / rate) / 1000)
if (seconds < 60) {
setEta(`${seconds}`)
} else {
const m = Math.floor(seconds / 60)
const s = seconds % 60
setEta(`${m}${s}`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchDecrypting, progress.current, progress.total, startTime])
useEffect(() => {
if (!showResultToast) return
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
return () => window.clearTimeout(timer)
}, [showResultToast, setShowResultToast])
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
return (
<>
{showToast && isBatchDecrypting && createPortal(
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{sessionName ? `${sessionName}` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span> {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{showResultToast && createPortal(
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<ImageIcon size={14} />
<span></span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="batch-inline-result-summary">
<div className="batch-inline-result-item success">
<CheckCircle size={14} />
<span> {result.success}</span>
</div>
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
<XCircle size={14} />
<span> {result.fail}</span>
</div>
</div>
</div>
</div>,
document.body
)}
</>
)
}

View File

@@ -139,6 +139,18 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
} }
@@ -212,4 +224,68 @@
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 8px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
} }

View File

@@ -26,6 +26,7 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [currentMonth, setCurrentMonth] = useState(new Date()) const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
// 点击外部关闭 // 点击外部关闭
@@ -185,12 +186,38 @@ function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChang
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}> <button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
<ChevronLeft size={16} /> <ChevronLeft size={16} />
</button> </button>
<span className="month-year">{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}</span> <span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{currentMonth.getFullYear()} {MONTH_NAMES[currentMonth.getMonth()]}
</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}> <button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
<ChevronRight size={16} /> <ChevronRight size={16} />
</button> </button>
</div> </div>
{renderCalendar()} {showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
<ChevronLeft size={14} />
</button>
<span className="year-label">{currentMonth.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
<ChevronRight size={14} />
</button>
</div>
<div className="month-grid">
{MONTH_NAMES.map((name, i) => (
<button
key={i}
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : renderCalendar()}
<div className="selection-hint"> <div className="selection-hint">
{selectingStart ? '请选择开始日期' : '请选择结束日期'} {selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div> </div>

View File

@@ -14,7 +14,6 @@ export function GlobalSessionMonitor() {
} = useChatStore() } = useChatStore()
const sessionsRef = useRef(sessions) const sessionsRef = useRef(sessions)
// 保持 ref 同步 // 保持 ref 同步
useEffect(() => { useEffect(() => {
sessionsRef.current = sessions sessionsRef.current = sessions
@@ -47,9 +46,10 @@ export function GlobalSessionMonitor() {
return () => { return () => {
removeListener() removeListener()
} }
} else {
} }
return () => { } return () => { }
}, []) // 空依赖数组 - 主要是静态的 }, [])
const refreshSessions = async () => { const refreshSessions = async () => {
try { try {

View File

@@ -75,6 +75,18 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
.nav-btn { .nav-btn {
@@ -97,6 +109,70 @@
} }
} }
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
} }
.calendar-grid { .calendar-grid {

View File

@@ -24,6 +24,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime()) const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date()) const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate)) const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
if (!isOpen) return null if (!isOpen) return null
@@ -137,7 +138,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
<span className="current-month"> <span className="current-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1} {calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span> </span>
<button <button
@@ -148,6 +149,31 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
</button> </button>
</div> </div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}> <div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && ( {loadingDates && (
<div className="calendar-loading"> <div className="calendar-loading">
@@ -174,6 +200,7 @@ const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
))} ))}
</div> </div>
</div> </div>
)}
</div> </div>
<div className="quick-options"> <div className="quick-options">

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from 'react' import { useState, useEffect, useRef } from 'react'
import * as configService from '../services/config'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react' import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss' import './LockScreen.scss'
@@ -9,14 +8,6 @@ interface LockScreenProps {
useHello?: boolean useHello?: boolean
} }
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) { export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -49,19 +40,9 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
const quickStartHello = async () => { const quickStartHello = async () => {
try { try {
// 如果父组件已经告诉我们要用 Hello直接开始不等待 IPC if (useHello) {
let shouldUseHello = useHello
// 为了稳健,如果 prop 没传(虽然现在都传了),再 check 一次 config
if (!shouldUseHello) {
shouldUseHello = await configService.getAuthUseHello()
}
if (shouldUseHello) {
// 标记为可用,显示按钮
setHelloAvailable(true) setHelloAvailable(true)
setShowHello(true) setShowHello(true)
// 立即执行验证 (0延迟)
verifyHello() verifyHello()
} }
} catch (e) { } catch (e) {
@@ -96,25 +77,19 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS
e?.preventDefault() e?.preventDefault()
if (!password || isUnlocked) return if (!password || isUnlocked) return
// 如果正在进行 Hello 验证它会自动失败或被取代UI上不用特意取消
// 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可
// 不再检查 isVerifying因为我们允许打断 Hello
setIsVerifying(true) setIsVerifying(true)
setError('') setError('')
try { try {
const storedHash = await configService.getAuthPassword() // 发送原始密码到主进程,由主进程验证并解密密钥
const inputHash = await sha256(password) const result = await window.electronAPI.auth.unlock(password)
if (inputHash === storedHash) { if (result.success) {
handleUnlock() handleUnlock()
} else { } else {
setError('密码错误') setError(result.error || '密码错误')
setPassword('') setPassword('')
setIsVerifying(false) setIsVerifying(false)
// 如果密码错误,是否重新触发 Hello?
// 用户可能想重试密码,暂时不自动触发
} }
} catch (e) { } catch (e) {
setError('验证失败') setError('验证失败')

View File

@@ -6,6 +6,13 @@
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
// 浅色模式下使用不透明背景,避免透明窗口中通知过于透明
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
}
border-radius: 12px; border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px; padding: 12px;
@@ -39,7 +46,7 @@
backdrop-filter: none !important; backdrop-filter: none !important;
-webkit-backdrop-filter: none !important; -webkit-backdrop-filter: none !important;
// Ensure background is solid // 确保背景不透明
background: var(--bg-secondary, #2c2c2c); background: var(--bg-secondary, #2c2c2c);
color: var(--text-primary, #ffffff); color: var(--text-primary, #ffffff);

View File

@@ -2,7 +2,7 @@ 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, Aperture, UserCircle, Lock } from 'lucide-react' import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock } from 'lucide-react'
import { useAppStore } from '../stores/appStore' import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import './Sidebar.scss' import './Sidebar.scss'
function Sidebar() { function Sidebar() {
@@ -12,7 +12,7 @@ function Sidebar() {
const setLocked = useAppStore(state => state.setLocked) const setLocked = useAppStore(state => state.setLocked)
useEffect(() => { useEffect(() => {
configService.getAuthEnabled().then(setAuthEnabled) window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
}, []) }, [])
const isActive = (path: string) => { const isActive = (path: string) => {

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react'
import { Search, Calendar, User, X, Filter, Check } from 'lucide-react'
import { Avatar } from '../Avatar'
// import JumpToDateDialog from '../JumpToDateDialog' // Assuming this is imported from parent or moved
interface Contact {
username: string
displayName: string
avatarUrl?: string
}
interface SnsFilterPanelProps {
searchKeyword: string
setSearchKeyword: (val: string) => void
jumpTargetDate?: Date
setJumpTargetDate: (date?: Date) => void
onOpenJumpDialog: () => void
selectedUsernames: string[]
setSelectedUsernames: (val: string[]) => void
contacts: Contact[]
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
jumpTargetDate,
setJumpTargetDate,
onOpenJumpDialog,
selectedUsernames,
setSelectedUsernames,
contacts,
contactSearch,
setContactSearch,
loading
}) => {
const filteredContacts = contacts.filter(c =>
c.displayName.toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
const toggleUserSelection = (username: string) => {
if (selectedUsernames.includes(username)) {
setSelectedUsernames(selectedUsernames.filter(u => u !== username))
} else {
setJumpTargetDate(undefined) // Reset date jump when selecting user
setSelectedUsernames([...selectedUsernames, username])
}
}
const clearFilters = () => {
setSearchKeyword('')
setSelectedUsernames([])
setJumpTargetDate(undefined)
}
return (
<aside className="sns-filter-panel">
<div className="filter-header">
<h3></h3>
{(searchKeyword || jumpTargetDate || selectedUsernames.length > 0) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
</button>
)}
</div>
<div className="filter-widgets">
{/* Search Widget */}
<div className="filter-widget search-widget">
<div className="widget-header">
<Search size={14} />
<span></span>
</div>
<div className="input-group">
<input
type="text"
placeholder="搜索动态内容..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
</div>
{/* Date Widget */}
<div className="filter-widget date-widget">
<div className="widget-header">
<Calendar size={14} />
<span></span>
</div>
<button
className={`date-picker-trigger ${jumpTargetDate ? 'active' : ''}`}
onClick={onOpenJumpDialog}
>
<span className="date-text">
{jumpTargetDate
? jumpTargetDate.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
: '选择日期...'}
</span>
{jumpTargetDate && (
<div
className="clear-date-btn"
onClick={(e) => {
e.stopPropagation()
setJumpTargetDate(undefined)
}}
>
<X size={12} />
</div>
)}
</button>
</div>
{/* Contact Widget */}
<div className="filter-widget contact-widget">
<div className="widget-header">
<User size={14} />
<span></span>
{selectedUsernames.length > 0 && (
<span className="badge">{selectedUsernames.length}</span>
)}
</div>
<div className="contact-search-bar">
<input
type="text"
placeholder="查找好友..."
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
<Search size={14} className="search-icon" />
{contactSearch && (
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
)}
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => (
<div
key={contact.username}
className={`contact-row ${selectedUsernames.includes(contact.username) ? 'selected' : ''}`}
onClick={() => toggleUserSelection(contact.username)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<span className="contact-name">{contact.displayName}</span>
</div>
))}
{filteredContacts.length === 0 && (
<div className="empty-state"></div>
)}
</div>
</div>
</div>
</aside>
)
}
function RefreshCw({ size, className }: { size?: number, className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || 24}
height={size || 24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
)
}

View File

@@ -0,0 +1,360 @@
import React, { useState, useRef } from 'react'
import { Play, Lock, Download, ImageOff } from 'lucide-react'
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
import { RefreshCw } from 'lucide-react'
interface SnsMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: {
url: string
thumb: string
token?: string
key?: string
encIdx?: string
}
}
interface SnsMediaGridProps {
mediaList: SnsMedia[]
postType?: number
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const extractVideoFrame = async (videoPath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'auto'
video.src = videoPath
video.muted = true
video.currentTime = 0 // Initial reset
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
const onSeeked = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
resolve(dataUrl)
} else {
reject(new Error('Canvas context failed'))
}
} catch (e) {
reject(e)
} finally {
// Cleanup
video.removeEventListener('seeked', onSeeked)
video.src = ''
video.load()
}
}
video.onloadedmetadata = () => {
if (video.duration === Infinity || isNaN(video.duration)) {
// Determine duration failed, try a fixed small offset
video.currentTime = 1
} else {
video.currentTime = Math.max(0.1, video.duration / 2)
}
}
video.onseeked = onSeeked
video.onerror = (e) => {
reject(new Error('Video load failed'))
}
})
}
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [loading, setLoading] = useState(true)
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
const retryCount = useRef(0)
const [retryKey, setRetryKey] = useState(0)
const [thumbSrc, setThumbSrc] = useState<string>('')
const [videoPath, setVideoPath] = useState<string>('')
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
const [isDecrypting, setIsDecrypting] = useState(false)
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
const isVideo = isSnsVideoUrl(media.url)
const isLive = !!media.livePhoto
const targetUrl = media.thumb || media.url
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
const skipDecrypt = postType === 7
// 视频重试失败时重试最多2次耗尽才标记删除
const videoRetryOrDelete = () => {
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
} else {
markDeleted()
}
}
// Simple effect to load image/decrypt
// Simple effect to load image/decrypt
React.useEffect(() => {
let cancelled = false
setLoading(true)
const load = async () => {
try {
if (!isVideo) {
// For images, we proxy to get the local path/base64
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success) {
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
markDeleted()
}
// Pre-load live photo video if needed
if (isLive && media.livePhoto?.url) {
window.electronAPI.sns.proxyImage({
url: media.livePhoto.url,
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
}).then((res: any) => {
if (!cancelled && res.success && res.videoPath) {
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
}
}).catch(() => { })
}
setLoading(false)
} else {
// Video logic: Decrypt -> Extract Frame
setIsGeneratingCover(true)
// First check if we already have it decryptable?
// Usually we need to call proxyImage with the video URL to decrypt it to cache
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success && result.videoPath) {
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
setVideoPath(localPath)
try {
const coverDataUrl = await extractVideoFrame(localPath)
if (!cancelled) setThumbSrc(coverDataUrl)
} catch (err) {
console.error('Frame extraction failed', err)
// 封面提取失败,用视频路径作为 fallback让 <video> 标签显示
if (!cancelled) setThumbSrc(localPath)
}
} else {
videoRetryOrDelete()
}
setIsGeneratingCover(false)
setLoading(false)
}
} catch (e) {
console.error(e)
if (!cancelled) {
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
}
setLoading(false)
setIsGeneratingCover(false)
}
}
}
load()
return () => { cancelled = true }
}, [media, isVideo, isLive, targetUrl, retryKey])
const handlePreview = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isVideo) {
// Decrypt video on demand if not already
if (!videoPath) {
setIsDecrypting(true)
try {
const res = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (res.success && res.videoPath) {
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
setVideoPath(local)
onPreview(local, true, undefined)
} else {
alert('视频解密失败')
}
} catch (e) {
console.error(e)
} finally {
setIsDecrypting(false)
}
} else {
onPreview(videoPath, true, undefined)
}
} else {
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
}
}
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation()
setLoading(true)
try {
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (result.success) {
const link = document.createElement('a')
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
if (result.dataUrl) {
link.href = result.dataUrl
} else if (result.videoPath) {
// For local video files, we need to fetch as blob to force download behavior
// or just use the file protocol url if the browser supports it
try {
const response = await fetch(`file://${result.videoPath}`)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
link.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch (err) {
console.error('Video fetch failed, falling back to direct link', err)
link.href = `file://${result.videoPath}`
}
}
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
alert('下载失败: 无法获取资源')
}
} catch (e) {
console.error('Download error:', e)
alert('下载出错')
} finally {
setLoading(false)
}
}
if (deleted) {
return (
<div className="sns-media-item deleted-media">
<div className="deleted-placeholder">
<ImageOff size={24} />
<span></span>
</div>
</div>
)
}
return (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
onClick={handlePreview}
>
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
<video
key={thumbSrc}
src={`${thumbSrc}#t=0.1`}
className="media-image"
preload="auto"
muted
playsInline
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={(e) => {
e.currentTarget.currentTime = 0.1
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => { if (!loading && !isVideo) markDeleted() }}
alt=""
/>
) : null}
{isGeneratingCover && (
<div className="media-decrypting-mask">
<RefreshCw className="spin" size={24} />
<span>...</span>
</div>
)}
{isVideo && (
<div className="media-badge video">
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
</div>
)}
{isLive && !isVideo && (
<div className="media-badge live">
<LivePhotoIcon size={16} />
</div>
)}
<div className="media-download-btn" onClick={handleDownload} title="下载">
<Download size={16} />
</div>
</div>
)
}
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
let gridClass = ''
if (count === 1) gridClass = 'grid-1'
else if (count === 2) gridClass = 'grid-2'
else if (count === 3) gridClass = 'grid-3'
else if (count === 4) gridClass = 'grid-4' // 2x2
else if (count <= 6) gridClass = 'grid-6' // 3 cols
else gridClass = 'grid-9' // 3x3
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)
}

View File

@@ -0,0 +1,302 @@
import React, { useState, useMemo } from 'react'
import { Heart, ChevronRight, ImageIcon, Download, Code, MoreHorizontal, Trash2 } from 'lucide-react'
import { SnsPost, SnsLinkCardData } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const normalizeUrlCandidate = (raw: string): string | null => {
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
if (!value) return null
if (!/^https?:\/\//i.test(value)) return null
return value
}
const simplifyUrlForCompare = (value: string): string => {
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
const [withoutQuery] = normalized.split('?')
return withoutQuery.replace(/\/+$/, '')
}
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
if (!xml) return []
const results: string[] = []
for (const tag of tags) {
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
let match: RegExpExecArray | null
while ((match = reg.exec(xml)) !== null) {
if (match[1]) results.push(match[1])
}
}
return results
}
const getUrlLikeStrings = (text: string): string[] => {
if (!text) return []
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
}
const isLikelyMediaAssetUrl = (url: string): boolean => {
const lower = url.toLowerCase()
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
}
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
// type 3 是链接类型,直接用 media[0] 的 url 和 thumb
if (post.type === 3) {
const url = post.media[0]?.url || post.linkUrl
if (!url) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
const title = titleCandidates
.map((v) => decodeHtmlEntities(v))
.find((v) => Boolean(v) && !/^https?:\/\//i.test(v))
return { url, title: title || '网页链接', thumb: post.media[0]?.thumb }
}
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
if (hasVideoMedia) return null
const mediaValues = post.media
.flatMap((item) => [item.url, item.thumb])
.filter((value): value is string => Boolean(value))
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
const urlCandidates: string[] = [
post.linkUrl || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
...getUrlLikeStrings(post.rawXml || ''),
...getUrlLikeStrings(post.contentDesc || '')
]
const normalizedCandidates = urlCandidates
.map(normalizeUrlCandidate)
.filter((value): value is string => Boolean(value))
const dedupedCandidates: string[] = []
const seen = new Set<string>()
for (const candidate of normalizedCandidates) {
if (seen.has(candidate)) continue
seen.add(candidate)
dedupedCandidates.push(candidate)
}
const linkUrl = dedupedCandidates.find((candidate) => {
const simplified = simplifyUrlForCompare(candidate)
if (mediaSet.has(simplified)) return false
if (isLikelyMediaAssetUrl(candidate)) return false
return true
})
if (!linkUrl) return null
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
const title = titleCandidates
.map((value) => decodeHtmlEntities(value))
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value))
return {
url: linkUrl,
title: title || '网页链接',
thumb: post.media[0]?.thumb || post.media[0]?.url
}
}
const SnsLinkCard = ({ card }: { card: SnsLinkCardData }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const hostname = useMemo(() => {
try {
return new URL(card.url).hostname.replace(/^www\./i, '')
} catch {
return card.url
}
}, [card.url])
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
try {
await window.electronAPI.shell.openExternal(card.url)
} catch (error) {
console.error('[SnsLinkCard] openExternal failed:', error)
}
}
return (
<button type="button" className="post-link-card" onClick={handleClick}>
<div className="link-thumb">
{card.thumb && !thumbFailed ? (
<img
src={card.thumb}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => setThumbFailed(true)}
/>
) : (
<div className="link-thumb-fallback">
<ImageIcon size={18} />
</div>
)}
</div>
<div className="link-meta">
<div className="link-title">{card.title}</div>
<div className="link-url">{hostname}</div>
</div>
<ChevronRight size={16} className="link-arrow" />
</button>
)
}
interface SnsPostItemProps {
post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const linkCard = buildLinkCardData(post)
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const showLinkCard = Boolean(linkCard) && post.media.length <= 1 && !hasVideoMedia
const showMediaGrid = post.media.length > 0 && !showLinkCard
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 解析微信表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
}
return `[${part}]`
}
return part
})
}
return (
<div className={`sns-post-item ${mediaDeleted ? 'post-deleted' : ''}`}>
<div className="post-avatar-col">
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={48}
shape="rounded"
/>
</div>
<div className="post-content-col">
<div className="post-header-row">
<div className="post-author-info">
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
<div className="post-header-actions">
{mediaDeleted && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span></span>
</span>
)}
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
</div>
</div>
{post.contentDesc && (
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} />
)}
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-interactions">
{post.likes.length > 0 && (
<div className="likes-block">
<Heart size={14} className="like-icon" />
<span className="likes-text">{post.likes.join('、')}</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-block">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-row">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text"></span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-colon"></span>
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,552 +0,0 @@
// AI 对话页面 - 简约大气风格
.ai-chat-page {
display: flex;
height: 100%;
width: 100%;
background: var(--bg-gradient);
color: var(--text-primary);
overflow: hidden;
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
// ========== 顶部 Header - 已移除 ==========
// 模型选择器现已集成到输入框
// ========== 聊天区域 ==========
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
background: var(--bg-secondary);
position: relative;
overflow: hidden;
// 空状态
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
.icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
svg {
width: 40px;
height: 40px;
color: var(--primary);
}
}
h2 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
p {
font-size: 14px;
color: var(--text-tertiary);
margin: 0;
}
}
// 消息列表
.messages-list {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
display: flex;
flex-direction: column;
gap: 20px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
.message-row {
display: flex;
gap: 12px;
max-width: 80%;
animation: messageIn 0.3s ease-out;
// 用户消息
&.user {
align-self: flex-end;
flex-direction: row-reverse;
.avatar {
background: var(--primary-light);
color: var(--primary);
}
.bubble {
background: var(--primary-gradient);
color: white;
border-radius: 18px 18px 4px 18px;
box-shadow: 0 2px 10px color-mix(in srgb, var(--primary) 20%, transparent);
.content {
color: white;
}
}
}
// AI 消息
&.ai {
align-self: flex-start;
.avatar {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.bubble {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 18px 18px 18px 4px;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
}
.avatar {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.bubble {
padding: 12px 16px;
flex: 1;
min-width: 0;
.content,
.markdown-content {
font-size: 14px;
line-height: 1.6;
color: var(--text-primary);
word-wrap: break-word;
overflow-wrap: break-word;
}
// Markdown 样式
.markdown-content {
p {
margin: 0 0 0.8em;
&:last-child {
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: 1.5em;
}
h2 {
font-size: 1.3em;
}
h3 {
font-size: 1.1em;
}
ul,
ol {
margin: 0.5em 0;
padding-left: 1.5em;
}
li {
margin: 0.3em 0;
}
code {
background: var(--bg-tertiary);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}
pre {
background: var(--bg-tertiary);
padding: 12px;
border-radius: 8px;
overflow-x: auto;
margin: 0.8em 0;
code {
background: none;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--primary);
padding-left: 12px;
margin: 0.8em 0;
color: var(--text-secondary);
}
a {
color: var(--primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
strong {
font-weight: 600;
color: var(--text-primary);
}
hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
th,
td {
border: 1px solid var(--border-color);
padding: 8px 12px;
text-align: left;
}
th {
background: var(--bg-tertiary);
font-weight: 600;
}
}
}
}
}
.list-spacer {
height: 100px;
flex-shrink: 0;
}
}
// 输入区域
.input-area {
position: absolute;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 64px);
max-width: 800px;
z-index: 10;
.input-wrapper {
display: flex;
align-items: flex-end;
gap: 10px;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 10px 14px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
&:focus-within {
border-color: var(--primary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
textarea {
flex: 1;
min-height: 24px;
max-height: 120px;
padding: 8px 0;
background: transparent;
border: none;
resize: none;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
line-height: 1.5;
&:focus {
outline: none;
}
&::placeholder {
color: var(--text-tertiary);
}
&:disabled {
cursor: not-allowed;
}
}
.input-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
// 模型选择器
.model-selector {
position: relative;
.model-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: auto;
height: 36px;
padding: 6px 12px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
color: var(--text-secondary);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
transition: all 0.2s ease;
flex-shrink: 0;
svg {
flex-shrink: 0;
&.spin {
animation: spin 1s linear infinite;
}
}
&:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--text-primary);
}
&.loaded {
background: color-mix(in srgb, var(--primary) 15%, transparent);
border-color: var(--primary);
color: var(--primary);
}
&.loading {
opacity: 0.7;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.model-dropdown {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--card-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 100;
overflow: hidden;
animation: dropdownIn 0.2s ease-out;
min-width: 140px;
.model-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
color: var(--text-primary);
transition: background 0.15s ease;
white-space: nowrap;
&:hover:not(.disabled) {
background: var(--bg-hover);
}
&.active {
background: color-mix(in srgb, var(--primary) 20%, transparent);
color: var(--primary);
font-weight: 600;
.check {
color: var(--primary);
}
}
.check {
margin-left: 8px;
color: var(--text-tertiary);
font-weight: 600;
}
}
}
}
.mode-toggle {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
color: var(--text-tertiary);
transition: all 0.2s ease;
flex-shrink: 0;
&:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: color-mix(in srgb, var(--primary) 15%, transparent);
border-color: var(--primary);
color: var(--primary);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.send-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--primary-gradient);
border: none;
border-radius: 10px;
cursor: pointer;
color: white;
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px color-mix(in srgb, var(--primary) 25%, transparent);
&:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px color-mix(in srgb, var(--primary) 35%, transparent);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:disabled {
background: var(--bg-tertiary);
color: var(--text-tertiary);
box-shadow: none;
cursor: not-allowed;
}
}
}
}
}
}
}
@keyframes messageIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes dropdownIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@@ -1,391 +0,0 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { Send, Bot, User, Cpu, ChevronDown, Loader2 } from 'lucide-react'
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'
import { engineService, PRESET_MODELS, ModelInfo } from '../services/EngineService'
import { MessageBubble } from '../components/MessageBubble'
import './AIChatPage.scss'
interface ChatMessage {
id: string;
role: 'user' | 'ai';
content: string;
timestamp: number;
}
// 消息数量限制,避免内存过载
const MAX_MESSAGES = 200
export default function AIChatPage() {
const [input, setInput] = useState('')
const [messages, setMessages] = useState<ChatMessage[]>([])
const [isTyping, setIsTyping] = useState(false)
const [models, setModels] = useState<ModelInfo[]>([...PRESET_MODELS])
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [modelLoaded, setModelLoaded] = useState(false)
const [loadingModel, setLoadingModel] = useState(false)
const [isThinkingMode, setIsThinkingMode] = useState(true)
const [showModelDropdown, setShowModelDropdown] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// 流式渲染优化:使用 ref 缓存内容,使用 RAF 批量更新
const streamingContentRef = useRef('')
const streamingMessageIdRef = useRef<string | null>(null)
const rafIdRef = useRef<number | null>(null)
useEffect(() => {
checkModelsStatus()
// 初始化Llama服务延迟初始化用户进入此页面时启动
const initLlama = async () => {
try {
await window.electronAPI.llama?.init()
console.log('[AIChatPage] Llama service initialized')
} catch (e) {
console.error('[AIChatPage] Failed to initialize Llama:', e)
}
}
initLlama()
// 清理函数:组件卸载时释放所有资源
return () => {
// 取消未完成的 RAF
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
// 清理 engine service 的回调引用
engineService.clearCallbacks()
}
}, [])
// 监听页面卸载事件,确保资源释放
useEffect(() => {
const handleBeforeUnload = () => {
// 清理回调和监听器
engineService.dispose()
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [])
// 点击外部关闭下拉框
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowModelDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const scrollToBottom = useCallback(() => {
// 使用 virtuoso 的 scrollToIndex 方法滚动到底部
if (virtuosoRef.current && messages.length > 0) {
virtuosoRef.current.scrollToIndex({
index: messages.length - 1,
behavior: 'smooth'
})
}
}, [messages.length])
const checkModelsStatus = async () => {
const updatedModels = await Promise.all(models.map(async (m) => {
const exists = await engineService.checkModelExists(m.path)
return { ...m, downloaded: exists }
}))
setModels(updatedModels)
// Auto-select first available model
if (!selectedModel) {
const available = updatedModels.find(m => m.downloaded)
if (available) {
setSelectedModel(available.path)
}
}
}
// 自动加载模型
const handleLoadModel = async (modelPath?: string) => {
const pathToLoad = modelPath || selectedModel
if (!pathToLoad) return false
setLoadingModel(true)
try {
await engineService.loadModel(pathToLoad)
// Initialize session with system prompt
await engineService.createSession("You are a helpful AI assistant.")
setModelLoaded(true)
return true
} catch (e) {
console.error("Load failed", e)
alert("模型加载失败: " + String(e))
return false
} finally {
setLoadingModel(false)
}
}
// 选择模型(如果有多个)
const handleSelectModel = (modelPath: string) => {
setSelectedModel(modelPath)
setShowModelDropdown(false)
}
// 获取可用的已下载模型
const availableModels = models.filter(m => m.downloaded)
const selectedModelInfo = models.find(m => m.path === selectedModel)
// 优化的流式更新函数:使用 RAF 批量更新
const updateStreamingMessage = useCallback(() => {
if (!streamingMessageIdRef.current) return
setMessages(prev => prev.map(msg =>
msg.id === streamingMessageIdRef.current
? { ...msg, content: streamingContentRef.current }
: msg
))
rafIdRef.current = null
}, [])
// Token 回调:使用 RAF 批量更新 UI
const handleToken = useCallback((token: string) => {
streamingContentRef.current += token
// 使用 requestAnimationFrame 批量更新,避免频繁渲染
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(updateStreamingMessage)
}
}, [updateStreamingMessage])
const handleSend = async () => {
if (!input.trim() || isTyping) return
// 如果模型未加载,先自动加载
if (!modelLoaded) {
if (!selectedModel) {
alert("请先下载模型(设置页面)")
return
}
const loaded = await handleLoadModel()
if (!loaded) return
}
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: Date.now()
}
setMessages(prev => {
const newMessages = [...prev, userMsg]
// 限制消息数量,避免内存过载
return newMessages.length > MAX_MESSAGES
? newMessages.slice(-MAX_MESSAGES)
: newMessages
})
setInput('')
setIsTyping(true)
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
const aiMsgId = (Date.now() + 1).toString()
streamingContentRef.current = ''
streamingMessageIdRef.current = aiMsgId
// Optimistic update for AI message start
setMessages(prev => {
const newMessages = [...prev, {
id: aiMsgId,
role: 'ai' as const,
content: '',
timestamp: Date.now()
}]
return newMessages.length > MAX_MESSAGES
? newMessages.slice(-MAX_MESSAGES)
: newMessages
})
// Append thinking command based on mode
const msgWithSuffix = input + (isThinkingMode ? " /think" : " /no_think")
try {
await engineService.chat(msgWithSuffix, handleToken, { thinking: isThinkingMode })
} catch (e) {
console.error("Chat failed", e)
setMessages(prev => [...prev, {
id: Date.now().toString(),
role: 'ai',
content: "❌ Error: Failed to get response from AI.",
timestamp: Date.now()
}])
} finally {
setIsTyping(false)
streamingMessageIdRef.current = null
// 确保最终状态同步
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current)
updateStreamingMessage()
}
}
}
// 渲染模型选择按钮(集成在输入框作为下拉项)
const renderModelSelector = () => {
// 没有可用模型
if (availableModels.length === 0) {
return (
<button
className="model-btn disabled"
title="请先在设置页面下载模型"
>
<Bot size={16} />
<span></span>
</button>
)
}
// 只有一个模型,直接显示
if (availableModels.length === 1) {
return (
<button
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
title={modelLoaded ? "模型已就绪" : "发送消息时自动加载"}
>
{loadingModel ? (
<Loader2 size={16} className="spin" />
) : (
<Bot size={16} />
)}
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '模型'}</span>
</button>
)
}
// 多个模型,显示下拉选择
return (
<div className="model-selector" ref={dropdownRef}>
<button
className={`model-btn ${modelLoaded ? 'loaded' : ''} ${loadingModel ? 'loading' : ''}`}
onClick={() => !loadingModel && setShowModelDropdown(!showModelDropdown)}
title="点击选择模型"
>
{loadingModel ? (
<Loader2 size={16} className="spin" />
) : (
<Bot size={16} />
)}
<span>{loadingModel ? '加载中' : selectedModelInfo?.name || '选择模型'}</span>
<ChevronDown size={13} className={showModelDropdown ? 'rotate' : ''} />
</button>
{showModelDropdown && (
<div className="model-dropdown">
{availableModels.map(model => (
<div
key={model.path}
className={`model-option ${selectedModel === model.path ? 'active' : ''}`}
onClick={() => handleSelectModel(model.path)}
>
<span>{model.name}</span>
{selectedModel === model.path && (
<span className="check"></span>
)}
</div>
))}
</div>
)}
</div>
)
}
return (
<div className="ai-chat-page">
<div className="chat-main">
{messages.length === 0 ? (
<div className="empty-state">
<div className="icon">
<Bot size={40} />
</div>
<h2>AI </h2>
<p>
{availableModels.length === 0
? "请先在设置页面下载模型"
: "输入消息开始对话,模型将自动加载"
}
</p>
</div>
) : (
<Virtuoso
ref={virtuosoRef}
data={messages}
className="messages-list"
initialTopMostItemIndex={messages.length - 1}
followOutput="smooth"
itemContent={(index, message) => (
<MessageBubble key={message.id} message={message} />
)}
components={{
Footer: () => <div className="list-spacer" />
}}
/>
)}
<div className="input-area">
<div className="input-wrapper">
<textarea
ref={textareaRef}
value={input}
onChange={e => {
setInput(e.target.value)
e.target.style.height = 'auto'
e.target.style.height = `${Math.min(e.target.scrollHeight, 120)}px`
}}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
// Reset height after send
if (textareaRef.current) textareaRef.current.style.height = 'auto'
}
}}
placeholder={availableModels.length === 0 ? "请先下载模型..." : "输入消息..."}
disabled={availableModels.length === 0 || loadingModel}
rows={1}
/>
<div className="input-actions">
{renderModelSelector()}
<button
className={`mode-toggle ${isThinkingMode ? 'active' : ''}`}
onClick={() => setIsThinkingMode(!isThinkingMode)}
title={isThinkingMode ? "深度思考模式已开启" : "深度思考模式已关闭"}
disabled={availableModels.length === 0}
>
<Cpu size={18} />
</button>
<button
className="send-btn"
onClick={handleSend}
disabled={!input.trim() || availableModels.length === 0 || isTyping || loadingModel}
>
<Send size={18} />
</button>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -45,6 +45,12 @@
font-weight: 600; font-weight: 600;
color: var(--primary); color: var(--primary);
} }
.error-actions {
display: flex;
align-items: center;
gap: 8px;
}
} }
.page-header { .page-header {
@@ -521,4 +527,4 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
} }

View File

@@ -108,6 +108,7 @@ function AnalyticsPage() {
}, [loadExcludedUsernames]) }, [loadExcludedUsernames])
const handleRefresh = () => loadData(true) const handleRefresh = () => loadData(true)
const isNoSessionError = error?.includes('未找到消息会话') ?? false
const loadExcludeCandidates = useCallback(async () => { const loadExcludeCandidates = useCallback(async () => {
setExcludeLoading(true) setExcludeLoading(true)
@@ -175,6 +176,23 @@ function AnalyticsPage() {
} }
} }
const handleResetExcluded = async () => {
try {
const result = await window.electronAPI.analytics.setExcludedUsernames([])
if (!result.success) {
setError(result.error || '重置排除好友失败')
return
}
setExcludedUsernames(new Set())
setDraftExcluded(new Set())
clearCache()
await window.electronAPI.cache.clearAnalytics()
await loadData(true)
} catch (e) {
setError(`重置排除好友失败: ${String(e)}`)
}
}
const visibleExcludeCandidates = excludeCandidates const visibleExcludeCandidates = excludeCandidates
.filter((candidate) => { .filter((candidate) => {
const query = excludeQuery.trim().toLowerCase() const query = excludeQuery.trim().toLowerCase()
@@ -355,6 +373,22 @@ function AnalyticsPage() {
) )
} }
if (error && !isLoaded && isNoSessionError && excludedUsernames.size > 0) {
return (
<div className="error-container">
<p>{error}</p>
<div className="error-actions">
<button className="btn btn-secondary" onClick={handleResetExcluded}>
</button>
<button className="btn btn-primary" onClick={() => loadData(true)}>
</button>
</div>
</div>
)
}
if (error && !isLoaded) { if (error && !isLoaded) {
return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}></button></div>) return (<div className="error-container"><p>{error}</p><button className="btn btn-primary" onClick={() => loadData(true)}></button></div>)
} }

View File

@@ -1041,12 +1041,13 @@
// 链接卡片消息样式 // 链接卡片消息样式
.link-message { .link-message {
width: 280px; width: 280px;
background: var(--card-bg); background: var(--card-inner-bg);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
&:hover { &:hover {
background: var(--bg-hover); background: var(--bg-hover);
@@ -1114,19 +1115,362 @@
} }
} }
} }
.appmsg-meta-badge {
font-size: 11px;
line-height: 1;
color: var(--primary);
background: rgba(127, 127, 127, 0.08);
border: 1px solid rgba(127, 127, 127, 0.18);
border-radius: 999px;
padding: 3px 7px;
align-self: flex-start;
white-space: nowrap;
}
.link-desc-block {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.appmsg-url-line {
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.appmsg-rich-card {
.link-header {
flex-direction: column;
align-items: flex-start;
}
}
}
.link-thumb.theme-adaptive,
.miniapp-thumb.theme-adaptive {
transition: filter 0.2s ease;
}
[data-mode="dark"] {
.link-thumb.theme-adaptive,
.miniapp-thumb.theme-adaptive {
filter: invert(1) hue-rotate(180deg);
}
} }
// 适配发送出去的消息中的链接卡片 // 适配发送出去的消息中的链接卡片
.message-bubble.sent .link-message { .message-bubble.sent .link-message {
background: var(--card-bg); background: var(--sent-card-bg);
border: 1px solid var(--border-color); border: 1px solid rgba(255, 255, 255, 0.15);
&:hover {
background: var(--primary-hover);
border-color: rgba(255, 255, 255, 0.25);
}
.link-title { .link-title {
color: var(--text-primary); color: white;
} }
.link-desc { .link-desc {
color: rgba(255, 255, 255, 0.8);
}
.appmsg-url-line {
color: rgba(255, 255, 255, 0.6);
}
}
// ============= 专属消息卡片 =============
// 红包卡片
.hongbao-message {
width: 240px;
background: linear-gradient(135deg, #e25b4a 0%, #c94535 100%);
border-radius: 12px;
padding: 14px 16px;
display: flex;
gap: 12px;
align-items: center;
cursor: default;
.hongbao-icon {
flex-shrink: 0;
svg {
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1));
}
}
.hongbao-info {
flex: 1;
color: white;
.hongbao-greeting {
font-size: 15px;
font-weight: 500;
margin-bottom: 6px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.hongbao-label {
font-size: 12px;
opacity: 0.8;
}
}
}
// 礼物卡片
.gift-message {
width: 240px;
background: linear-gradient(135deg, #f7a8b8 0%, #e88fa0 100%);
border-radius: 12px;
padding: 14px 16px;
cursor: default;
.gift-img {
width: 100%;
border-radius: 8px;
margin-bottom: 10px;
object-fit: cover;
}
.gift-info {
color: white;
.gift-wish {
font-size: 15px;
font-weight: 500;
margin-bottom: 4px;
}
.gift-price {
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
}
.gift-label {
font-size: 12px;
opacity: 0.7;
}
}
}
// 视频号卡片
.channel-video-card {
width: 200px;
background: var(--card-inner-bg);
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--border-color);
position: relative;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
}
.channel-video-cover {
position: relative;
width: 100%;
height: 160px;
background: #000;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.channel-video-cover-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.channel-video-duration {
position: absolute;
bottom: 6px;
right: 6px;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 11px;
padding: 1px 5px;
border-radius: 3px;
}
}
.channel-video-info {
padding: 8px 10px;
.channel-video-title {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.channel-video-author {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary);
.channel-video-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
}
}
}
}
// 音乐卡片
.music-message {
width: 240px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
opacity: 0.85;
border-color: var(--primary);
}
.music-cover {
width: 80px;
align-self: stretch;
flex-shrink: 0;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary); color: var(--text-secondary);
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
.music-info {
flex: 1;
min-width: 0;
overflow: hidden;
padding: 10px;
.music-title {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-artist {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.music-source {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
// 位置消息卡片
.location-message {
width: 240px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--primary);
}
.location-text {
padding: 12px;
display: flex;
gap: 8px;
}
.location-icon {
flex-shrink: 0;
color: #e25b4a;
margin-top: 2px;
}
.location-info {
flex: 1;
min-width: 0;
.location-name {
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
color: var(--text-primary);
}
.location-label {
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.4;
}
}
.location-map {
position: relative;
height: 100px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
}
// 暗色模式下地图瓦片反色
[data-mode="dark"] {
.location-map img {
filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9);
} }
} }
@@ -1288,6 +1632,21 @@
z-index: 2; z-index: 2;
} }
.empty-chat-inline {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 0;
color: var(--text-tertiary);
font-size: 14px;
svg {
opacity: 0.4;
}
}
.message-list * { .message-list * {
-webkit-app-region: no-drag !important; -webkit-app-region: no-drag !important;
} }
@@ -1497,6 +1856,20 @@
} }
} }
// 卡片类消息:气泡变透明,让卡片自己做视觉容器(仅直接子元素,排除引用消息内的卡片)
.message-bubble .bubble-content:has(> .link-message),
.message-bubble .bubble-content:has(> .card-message),
.message-bubble .bubble-content:has(> .chat-record-message),
.message-bubble .bubble-content:has(> .official-message),
.message-bubble .bubble-content:has(> .channel-video-card),
.message-bubble .bubble-content:has(> .location-message) {
background: transparent !important;
padding: 0 !important;
border: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
.emoji-image { .emoji-image {
max-width: 120px; max-width: 120px;
max-height: 120px; max-height: 120px;
@@ -1566,6 +1939,23 @@
position: relative; position: relative;
display: inline-block; display: inline-block;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
.media-badge.live {
position: absolute;
top: 8px;
right: 8px;
left: auto;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.4);
border-radius: 50%;
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
color: white;
pointer-events: none;
}
} }
.image-update-button { .image-update-button {
@@ -2455,10 +2845,17 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px 14px;
background: var(--bg-tertiary); background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
min-width: 200px; min-width: 200px;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 0.85;
}
.card-icon { .card-icon {
flex-shrink: 0; flex-shrink: 0;
@@ -2483,6 +2880,18 @@
} }
} }
// 聊天记录消息 (合并转发)
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color) !important;
transition: opacity 0.2s ease;
cursor: pointer;
&:hover {
opacity: 0.85;
}
}
// 通话消息 // 通话消息
.call-message { .call-message {
display: flex; display: flex;
@@ -2720,12 +3129,14 @@
.card-message, .card-message,
.chat-record-message, .chat-record-message,
.miniapp-message { .miniapp-message,
background: rgba(255, 255, 255, 0.15); .appmsg-rich-card {
background: var(--sent-card-bg);
.card-name, .card-name,
.miniapp-title, .miniapp-title,
.source-name { .source-name,
.link-title {
color: white; color: white;
} }
@@ -2733,7 +3144,9 @@
.miniapp-label, .miniapp-label,
.chat-record-item, .chat-record-item,
.chat-record-meta-line, .chat-record-meta-line,
.chat-record-desc { .chat-record-desc,
.link-desc,
.appmsg-url-line {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }
@@ -2746,6 +3159,12 @@
.chat-record-more { .chat-record-more {
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
} }
.appmsg-meta-badge {
color: rgba(255, 255, 255, 0.92);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
}
} }
.call-message { .call-message {
@@ -3203,4 +3622,234 @@
} }
} }
} }
}
.miniapp-message-rich {
.miniapp-thumb {
width: 42px;
height: 42px;
border-radius: 8px;
object-fit: cover;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
flex-shrink: 0;
}
}
// 名片消息样式
.card-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
&:hover {
background: var(--bg-hover);
}
.card-icon {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.card-name {
font-size: 15px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-wxid {
font-size: 12px;
color: var(--text-tertiary);
}
.card-label {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
}
}
// 聊天记录消息外观
.chat-record-message {
background: var(--card-inner-bg) !important;
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
&:hover {
background: var(--bg-hover) !important;
}
.chat-record-list {
font-size: 13px;
color: var(--text-tertiary);
line-height: 1.6;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border-color);
.chat-record-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.source-name {
color: var(--text-secondary);
}
}
}
.chat-record-more {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
}
// 公众号文章图文消息外观 (大图模式)
.official-message {
display: flex;
flex-direction: column;
background: var(--card-inner-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
min-width: 240px;
max-width: 320px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
&:hover {
background: var(--bg-hover);
}
.official-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
.official-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.official-avatar-placeholder {
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
svg {
width: 14px;
height: 14px;
}
}
.official-name {
font-size: 13px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.official-body {
display: flex;
flex-direction: column;
.official-cover-wrapper {
position: relative;
width: 100%;
padding-top: 42.5%; // ~2.35:1 aspectRatio standard for WeChat article covers
background: var(--bg-secondary);
overflow: hidden;
.official-cover {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.official-title-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
padding: 24px 12px 10px;
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.7));
color: #fff;
font-size: 15px;
font-weight: 500;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.official-title-text {
padding: 0 12px 10px;
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.official-digest {
font-size: 13px;
color: var(--text-tertiary);
padding: 0 12px 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
} }

View File

@@ -4,9 +4,11 @@ import { useNavigate } from 'react-router-dom'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useChatStore } from '../stores/chatStore' import { useChatStore } from '../stores/chatStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore' import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
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 { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog' import { VoiceTranscribeDialog } from '../components/VoiceTranscribeDialog'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import { AnimatedStreamingText } from '../components/AnimatedStreamingText' import { AnimatedStreamingText } from '../components/AnimatedStreamingText'
import JumpToDateDialog from '../components/JumpToDateDialog' import JumpToDateDialog from '../components/JumpToDateDialog'
import * as configService from '../services/config' import * as configService from '../services/config'
@@ -26,6 +28,12 @@ interface XmlField {
path: string; path: string;
} }
interface BatchImageDecryptCandidate {
imageMd5?: string
imageDatName?: string
createTime?: number
}
// 尝试解析 XML 为可编辑字段 // 尝试解析 XML 为可编辑字段
function parseXmlToFields(xml: string): XmlField[] { function parseXmlToFields(xml: string): XmlField[] {
const fields: XmlField[] = [] const fields: XmlField[] = []
@@ -281,6 +289,8 @@ function ChatPage(_props: ChatPageProps) {
const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([]) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState<string[]>([])
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false)
const [hasInitialMessages, setHasInitialMessages] = useState(false) const [hasInitialMessages, setHasInitialMessages] = useState(false)
const [noMessageTable, setNoMessageTable] = useState(false)
const [fallbackDisplayName, setFallbackDisplayName] = useState<string | null>(null)
const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false) const [showVoiceTranscribeDialog, setShowVoiceTranscribeDialog] = useState(false)
const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null) const [pendingVoiceTranscriptRequest, setPendingVoiceTranscriptRequest] = useState<{ sessionId: string; messageId: string } | null>(null)
@@ -298,11 +308,16 @@ function ChatPage(_props: ChatPageProps) {
// 批量语音转文字相关状态(进度/结果 由全局 store 管理) // 批量语音转文字相关状态(进度/结果 由全局 store 管理)
const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore() const { isBatchTranscribing, progress: batchTranscribeProgress, showToast: showBatchProgress, startTranscribe, updateProgress, finishTranscribe, setShowToast: setShowBatchProgress } = useBatchTranscribeStore()
const { isBatchDecrypting, progress: batchDecryptProgress, startDecrypt, updateProgress: updateDecryptProgress, finishDecrypt, setShowToast: setShowBatchDecryptToast } = useBatchImageDecryptStore()
const [showBatchConfirm, setShowBatchConfirm] = useState(false) const [showBatchConfirm, setShowBatchConfirm] = useState(false)
const [batchVoiceCount, setBatchVoiceCount] = useState(0) const [batchVoiceCount, setBatchVoiceCount] = useState(0)
const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null) const [batchVoiceMessages, setBatchVoiceMessages] = useState<Message[] | null>(null)
const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([]) const [batchVoiceDates, setBatchVoiceDates] = useState<string[]>([])
const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set()) const [batchSelectedDates, setBatchSelectedDates] = useState<Set<string>>(new Set())
const [showBatchDecryptConfirm, setShowBatchDecryptConfirm] = useState(false)
const [batchImageMessages, setBatchImageMessages] = useState<BatchImageDecryptCandidate[] | null>(null)
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(new Set())
// 批量删除相关状态 // 批量删除相关状态
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
@@ -857,6 +872,10 @@ function ChatPage(_props: ChatPageProps) {
if (result.success && result.messages) { if (result.success && result.messages) {
if (offset === 0) { if (offset === 0) {
setMessages(result.messages) setMessages(result.messages)
if (result.messages.length === 0) {
setNoMessageTable(true)
setHasMoreMessages(false)
}
// 预取发送者信息:在关闭加载遮罩前处理 // 预取发送者信息:在关闭加载遮罩前处理
const unreadCount = session?.unreadCount ?? 0 const unreadCount = session?.unreadCount ?? 0
@@ -929,7 +948,7 @@ function ChatPage(_props: ChatPageProps) {
} }
setCurrentOffset(offset + result.messages.length) setCurrentOffset(offset + result.messages.length)
} else if (!result.success) { } else if (!result.success) {
setConnectionError(result.error || '加载消息失败') setNoMessageTable(true)
setHasMoreMessages(false) setHasMoreMessages(false)
} }
} catch (e) { } catch (e) {
@@ -1247,6 +1266,7 @@ function ChatPage(_props: ChatPageProps) {
useEffect(() => { useEffect(() => {
if (currentSessionId !== prevSessionRef.current) { if (currentSessionId !== prevSessionRef.current) {
prevSessionRef.current = currentSessionId prevSessionRef.current = currentSessionId
setNoMessageTable(false)
if (initialRevealTimerRef.current !== null) { if (initialRevealTimerRef.current !== null) {
window.clearTimeout(initialRevealTimerRef.current) window.clearTimeout(initialRevealTimerRef.current)
initialRevealTimerRef.current = null initialRevealTimerRef.current = null
@@ -1260,10 +1280,11 @@ function ChatPage(_props: ChatPageProps) {
}, [currentSessionId, messages.length, isLoadingMessages]) }, [currentSessionId, messages.length, isLoadingMessages])
useEffect(() => { useEffect(() => {
if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore) { if (currentSessionId && messages.length === 0 && !isLoadingMessages && !isLoadingMore && !noMessageTable) {
setHasInitialMessages(false)
loadMessages(currentSessionId, 0) loadMessages(currentSessionId, 0)
} }
}, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore]) }, [currentSessionId, messages.length, isLoadingMessages, isLoadingMore, noMessageTable])
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -1327,8 +1348,35 @@ function ChatPage(_props: ChatPageProps) {
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}, []) }, [])
// 获取当前会话信息 // 获取当前会话信息(从通讯录跳转时可能不在 sessions 列表中,构造 fallback
const currentSession = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined const currentSession = (() => {
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found || !currentSessionId) return found
return {
username: currentSessionId,
type: 0,
unreadCount: 0,
summary: '',
sortTimestamp: 0,
lastTimestamp: 0,
lastMsgType: 0,
displayName: fallbackDisplayName || currentSessionId,
} as ChatSession
})()
// 从通讯录跳转时,会话不在列表中,主动加载联系人显示名称
useEffect(() => {
if (!currentSessionId) return
const found = Array.isArray(sessions) ? sessions.find(s => s.username === currentSessionId) : undefined
if (found) {
setFallbackDisplayName(null)
return
}
loadContactInfoBatch([currentSessionId]).then(() => {
const cached = senderAvatarCache.get(currentSessionId)
if (cached?.displayName) setFallbackDisplayName(cached.displayName)
})
}, [currentSessionId, sessions])
// 判断是否为群聊 // 判断是否为群聊
const isGroupChat = (username: string) => username.includes('@chatroom') const isGroupChat = (username: string) => username.includes('@chatroom')
@@ -1398,6 +1446,37 @@ function ChatPage(_props: ChatPageProps) {
setShowBatchConfirm(true) setShowBatchConfirm(true)
}, [sessions, currentSessionId, isBatchTranscribing]) }, [sessions, currentSessionId, isBatchTranscribing])
const handleBatchDecrypt = useCallback(async () => {
if (!currentSessionId || isBatchDecrypting) return
const session = sessions.find(s => s.username === currentSessionId)
if (!session) {
alert('未找到当前会话')
return
}
const result = await window.electronAPI.chat.getAllImageMessages(currentSessionId)
if (!result.success || !result.images) {
alert(`获取图片消息失败: ${result.error || '未知错误'}`)
return
}
if (result.images.length === 0) {
alert('当前会话没有图片消息')
return
}
const dateSet = new Set<string>()
result.images.forEach((img: BatchImageDecryptCandidate) => {
if (img.createTime) dateSet.add(new Date(img.createTime * 1000).toISOString().slice(0, 10))
})
const sortedDates = Array.from(dateSet).sort((a, b) => b.localeCompare(a))
setBatchImageMessages(result.images)
setBatchImageDates(sortedDates)
setBatchImageSelectedDates(new Set(sortedDates))
setShowBatchDecryptConfirm(true)
}, [currentSessionId, isBatchDecrypting, sessions])
const handleExportCurrentSession = useCallback(() => { const handleExportCurrentSession = useCallback(() => {
if (!currentSessionId) return if (!currentSessionId) return
navigate('/export', { navigate('/export', {
@@ -1521,6 +1600,88 @@ function ChatPage(_props: ChatPageProps) {
const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates]) const selectAllBatchDates = useCallback(() => setBatchSelectedDates(new Set(batchVoiceDates)), [batchVoiceDates])
const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), []) const clearAllBatchDates = useCallback(() => setBatchSelectedDates(new Set()), [])
const confirmBatchDecrypt = useCallback(async () => {
if (!currentSessionId) return
const selected = batchImageSelectedDates
if (selected.size === 0) {
alert('请至少选择一个日期')
return
}
const images = (batchImageMessages || []).filter(img =>
img.createTime && selected.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
)
if (images.length === 0) {
alert('所选日期下没有图片消息')
return
}
const session = sessions.find(s => s.username === currentSessionId)
if (!session) return
setShowBatchDecryptConfirm(false)
setBatchImageMessages(null)
setBatchImageDates([])
setBatchImageSelectedDates(new Set())
startDecrypt(images.length, session.displayName || session.username)
let successCount = 0
let failCount = 0
for (let i = 0; i < images.length; i++) {
const img = images[i]
try {
const r = await window.electronAPI.image.decrypt({
sessionId: session.username,
imageMd5: img.imageMd5,
imageDatName: img.imageDatName,
force: false
})
if (r?.success) successCount++
else failCount++
} catch {
failCount++
}
updateDecryptProgress(i + 1, images.length)
if (i % 5 === 0) {
await new Promise(resolve => setTimeout(resolve, 0))
}
}
finishDecrypt(successCount, failCount)
}, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress])
const batchImageCountByDate = useMemo(() => {
const map = new Map<string, number>()
if (!batchImageMessages) return map
batchImageMessages.forEach(img => {
if (!img.createTime) return
const d = new Date(img.createTime * 1000).toISOString().slice(0, 10)
map.set(d, (map.get(d) ?? 0) + 1)
})
return map
}, [batchImageMessages])
const batchImageSelectedCount = useMemo(() => {
if (!batchImageMessages) return 0
return batchImageMessages.filter(img =>
img.createTime && batchImageSelectedDates.has(new Date(img.createTime * 1000).toISOString().slice(0, 10))
).length
}, [batchImageMessages, batchImageSelectedDates])
const toggleBatchImageDate = useCallback((date: string) => {
setBatchImageSelectedDates(prev => {
const next = new Set(prev)
if (next.has(date)) next.delete(date)
else next.add(date)
return next
})
}, [])
const selectAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set(batchImageDates)), [batchImageDates])
const clearAllBatchImageDates = useCallback(() => setBatchImageSelectedDates(new Set()), [])
const lastSelectedIdRef = useRef<number | null>(null) const lastSelectedIdRef = useRef<number | null>(null)
const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => { const handleToggleSelection = useCallback((localId: number, isShiftKey: boolean = false) => {
@@ -1960,6 +2121,26 @@ function ChatPage(_props: ChatPageProps) {
<Mic size={18} /> <Mic size={18} />
)} )}
</button> </button>
<button
className={`icon-btn batch-decrypt-btn${isBatchDecrypting ? ' transcribing' : ''}`}
onClick={() => {
if (isBatchDecrypting) {
setShowBatchDecryptToast(true)
} else {
handleBatchDecrypt()
}
}}
disabled={!currentSessionId}
title={isBatchDecrypting
? `批量解密中 (${batchDecryptProgress.current}/${batchDecryptProgress.total}),点击查看进度`
: '批量解密图片'}
>
{isBatchDecrypting ? (
<Loader2 size={18} className="spin" />
) : (
<ImageIcon size={18} />
)}
</button>
<button <button
className="icon-btn jump-to-time-btn" className="icon-btn jump-to-time-btn"
onClick={async () => { onClick={async () => {
@@ -2048,6 +2229,13 @@ function ChatPage(_props: ChatPageProps) {
</div> </div>
)} )}
{!isLoadingMessages && messages.length === 0 && !hasMoreMessages && (
<div className="empty-chat-inline">
<MessageSquare size={32} />
<span></span>
</div>
)}
{messages.map((msg, index) => { {messages.map((msg, index) => {
const prevMsg = index > 0 ? messages[index - 1] : undefined const prevMsg = index > 0 ? messages[index - 1] : undefined
const showDateDivider = shouldShowDateDivider(msg, prevMsg) const showDateDivider = shouldShowDateDivider(msg, prevMsg)
@@ -2318,6 +2506,66 @@ function ChatPage(_props: ChatPageProps) {
document.body document.body
)} )}
{/* 消息右键菜单 */} {/* 消息右键菜单 */}
{showBatchDecryptConfirm && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowBatchDecryptConfirm(false)}>
<div className="batch-modal-content batch-confirm-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
<ImageIcon size={20} />
<h3></h3>
</div>
<div className="batch-modal-body">
<p></p>
{batchImageDates.length > 0 && (
<div className="batch-dates-list-wrap">
<div className="batch-dates-actions">
<button type="button" className="batch-dates-btn" onClick={selectAllBatchImageDates}></button>
<button type="button" className="batch-dates-btn" onClick={clearAllBatchImageDates}></button>
</div>
<ul className="batch-dates-list">
{batchImageDates.map(dateStr => {
const count = batchImageCountByDate.get(dateStr) ?? 0
const checked = batchImageSelectedDates.has(dateStr)
return (
<li key={dateStr}>
<label className="batch-date-row">
<input
type="checkbox"
checked={checked}
onChange={() => toggleBatchImageDate(dateStr)}
/>
<span className="batch-date-label">{formatBatchDateLabel(dateStr)}</span>
<span className="batch-date-count">{count} </span>
</label>
</li>
)
})}
</ul>
</div>
)}
<div className="batch-info">
<div className="info-item">
<span className="label">:</span>
<span className="value">{batchImageSelectedDates.size} {batchImageSelectedCount} </span>
</div>
</div>
<div className="batch-warning">
<AlertCircle size={16} />
<span></span>
</div>
</div>
<div className="batch-modal-footer">
<button className="btn-secondary" onClick={() => setShowBatchDecryptConfirm(false)}>
</button>
<button className="btn-primary" onClick={confirmBatchDecrypt}>
<ImageIcon size={16} />
</button>
</div>
</div>
</div>,
document.body
)}
{contextMenu && createPortal( {contextMenu && createPortal(
<> <>
<div className="context-menu-overlay" onClick={() => setContextMenu(null)} <div className="context-menu-overlay" onClick={() => setContextMenu(null)}
@@ -2583,6 +2831,7 @@ function MessageBubble({
const [imageInView, setImageInView] = useState(false) const [imageInView, setImageInView] = useState(false)
const imageForceHdAttempted = useRef<string | null>(null) const imageForceHdAttempted = useRef<string | null>(null)
const imageForceHdPending = useRef(false) const imageForceHdPending = useRef(false)
const [imageLiveVideoPath, setImageLiveVideoPath] = useState<string | undefined>(undefined)
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)
@@ -2811,7 +3060,8 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, result.localPath) imageDataUrlCache.set(imageCacheKey, result.localPath)
setImageLocalPath(result.localPath) setImageLocalPath(result.localPath)
setImageHasUpdate(false) setImageHasUpdate(false)
return if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
return result
} }
} }
@@ -2822,7 +3072,7 @@ function MessageBubble({
imageDataUrlCache.set(imageCacheKey, dataUrl) imageDataUrlCache.set(imageCacheKey, dataUrl)
setImageLocalPath(dataUrl) setImageLocalPath(dataUrl)
setImageHasUpdate(false) setImageHasUpdate(false)
return return { success: true, localPath: dataUrl } as any
} }
if (!silent) setImageError(true) if (!silent) setImageError(true)
} catch { } catch {
@@ -2830,6 +3080,7 @@ function MessageBubble({
} finally { } finally {
if (!silent) setImageLoading(false) if (!silent) setImageLoading(false)
} }
return { success: false } as any
}, [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(() => { const triggerForceHd = useCallback(() => {
@@ -2860,6 +3111,55 @@ function MessageBubble({
void requestImageDecrypt() void requestImageDecrypt()
}, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username]) }, [message.imageDatName, message.imageMd5, message.localId, requestImageDecrypt, session.username])
const handleOpenImageViewer = useCallback(async () => {
if (!imageLocalPath) return
let finalImagePath = imageLocalPath
let finalLiveVideoPath = imageLiveVideoPath || undefined
// If current cache is a thumbnail, wait for a silent force-HD decrypt before opening viewer.
if (imageHasUpdate) {
try {
const upgraded = await requestImageDecrypt(true, true)
if (upgraded?.success && upgraded.localPath) {
finalImagePath = upgraded.localPath
finalLiveVideoPath = upgraded.liveVideoPath || finalLiveVideoPath
}
} catch { }
}
// One more resolve helps when background/batch decrypt has produced a clearer image or live video
// but local component state hasn't caught up yet.
if (message.imageMd5 || message.imageDatName) {
try {
const resolved = await window.electronAPI.image.resolveCache({
sessionId: session.username,
imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName
})
if (resolved?.success && resolved.localPath) {
finalImagePath = resolved.localPath
finalLiveVideoPath = resolved.liveVideoPath || finalLiveVideoPath
imageDataUrlCache.set(imageCacheKey, resolved.localPath)
setImageLocalPath(resolved.localPath)
if (resolved.liveVideoPath) setImageLiveVideoPath(resolved.liveVideoPath)
setImageHasUpdate(Boolean(resolved.hasUpdate))
}
} catch { }
}
void window.electronAPI.window.openImageViewerWindow(finalImagePath, finalLiveVideoPath)
}, [
imageHasUpdate,
imageLiveVideoPath,
imageLocalPath,
imageCacheKey,
message.imageDatName,
message.imageMd5,
requestImageDecrypt,
session.username
])
useEffect(() => { useEffect(() => {
return () => { return () => {
if (imageClickTimerRef.current) { if (imageClickTimerRef.current) {
@@ -2878,7 +3178,7 @@ function MessageBubble({
sessionId: session.username, sessionId: session.username,
imageMd5: message.imageMd5 || undefined, imageMd5: message.imageMd5 || undefined,
imageDatName: message.imageDatName imageDatName: message.imageDatName
}).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }) => { }).then((result: { success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; 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)
@@ -2886,6 +3186,7 @@ function MessageBubble({
setImageLocalPath(result.localPath) setImageLocalPath(result.localPath)
setImageError(false) setImageError(false)
} }
if (result.liveVideoPath) setImageLiveVideoPath(result.liveVideoPath)
setImageHasUpdate(Boolean(result.hasUpdate)) setImageHasUpdate(Boolean(result.hasUpdate))
} }
}).catch(() => { }) }).catch(() => { })
@@ -3380,15 +3681,15 @@ function MessageBubble({
src={imageLocalPath} src={imageLocalPath}
alt="图片" alt="图片"
className="image-message" className="image-message"
onClick={() => { onClick={() => { void handleOpenImageViewer() }}
if (imageHasUpdate) {
void requestImageDecrypt(true, true)
}
void window.electronAPI.window.openImageViewerWindow(imageLocalPath)
}}
onLoad={() => setImageError(false)} onLoad={() => setImageError(false)}
onError={() => setImageError(true)} onError={() => setImageError(true)}
/> />
{imageLiveVideoPath && (
<div className="media-badge live">
<LivePhotoIcon size={14} />
</div>
)}
</div> </div>
</> </>
)} )}
@@ -3643,16 +3944,24 @@ function MessageBubble({
// 名片消息 // 名片消息
if (isCard) { if (isCard) {
const cardName = message.cardNickname || message.cardUsername || '未知联系人' const cardName = message.cardNickname || message.cardUsername || '未知联系人'
const cardAvatar = message.cardAvatarUrl
return ( return (
<div className="card-message"> <div className="card-message">
<div className="card-icon"> <div className="card-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> {cardAvatar ? (
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /> <img src={cardAvatar} alt="" style={{ width: '40px', height: '40px', objectFit: 'cover', borderRadius: '8px' }} referrerPolicy="no-referrer" />
<circle cx="12" cy="7" r="4" /> ) : (
</svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
)}
</div> </div>
<div className="card-info"> <div className="card-info">
<div className="card-name">{cardName}</div> <div className="card-name">{cardName}</div>
{message.cardUsername && message.cardUsername !== message.cardNickname && (
<div className="card-wxid">: {message.cardUsername}</div>
)}
<div className="card-label"></div> <div className="card-label"></div>
</div> </div>
</div> </div>
@@ -3671,7 +3980,346 @@ function MessageBubble({
) )
} }
// 位置消息
if (message.localType === 48) {
const raw = message.rawContent || ''
const poiname = raw.match(/poiname="([^"]*)"/)?.[1] || message.locationPoiname || '位置'
const label = raw.match(/label="([^"]*)"/)?.[1] || message.locationLabel || ''
const lat = parseFloat(raw.match(/x="([^"]*)"/)?.[1] || String(message.locationLat || 0))
const lng = parseFloat(raw.match(/y="([^"]*)"/)?.[1] || String(message.locationLng || 0))
const zoom = 15
const tileX = Math.floor((lng + 180) / 360 * Math.pow(2, zoom))
const latRad = lat * Math.PI / 180
const tileY = Math.floor((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2 * Math.pow(2, zoom))
const mapTileUrl = (lat && lng)
? `https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x=${tileX}&y=${tileY}&z=${zoom}`
: ''
return (
<div className="location-message" onClick={() => window.electronAPI.shell.openExternal(`https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(poiname || label)}`)}>
<div className="location-text">
<div className="location-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<div className="location-info">
{poiname && <div className="location-name">{poiname}</div>}
{label && <div className="location-label">{label}</div>}
</div>
</div>
{mapTileUrl && (
<div className="location-map">
<img src={mapTileUrl} alt="地图" referrerPolicy="no-referrer" />
</div>
)}
</div>
)
}
// 链接消息 (AppMessage) // 链接消息 (AppMessage)
const appMsgRichPreview = (() => {
const rawXml = message.rawContent || ''
if (!rawXml || (!rawXml.includes('<appmsg') && !rawXml.includes('&lt;appmsg'))) return null
let doc: Document | null = null
const getDoc = () => {
if (doc) return doc
try {
const start = rawXml.indexOf('<msg>')
const xml = start >= 0 ? rawXml.slice(start) : rawXml
doc = new DOMParser().parseFromString(xml, 'text/xml')
} catch {
doc = null
}
return doc
}
const q = (selector: string) => getDoc()?.querySelector(selector)?.textContent?.trim() || ''
const xmlType = message.xmlType || q('appmsg > type') || q('type')
// type 57: 引用回复消息,解析 refermsg 渲染为引用样式
if (xmlType === '57') {
const replyText = q('title') || cleanMessageContent(message.parsedContent) || ''
const referContent = q('refermsg > content') || ''
const referSender = q('refermsg > displayname') || ''
return (
<div className="bubble-content">
<div className="quoted-message">
{referSender && <span className="quoted-sender">{referSender}</span>}
<span className="quoted-text">{renderTextWithEmoji(cleanMessageContent(referContent))}</span>
</div>
<div className="message-text">{renderTextWithEmoji(cleanMessageContent(replyText))}</div>
</div>
)
}
const title = message.linkTitle || q('title') || cleanMessageContent(message.parsedContent) || 'Card'
const desc = message.appMsgDesc || q('des')
const url = message.linkUrl || q('url')
const thumbUrl = message.linkThumb || message.appMsgThumbUrl || q('thumburl') || q('cdnthumburl') || q('cover') || q('coverurl')
const musicUrl = message.appMsgMusicUrl || message.appMsgDataUrl || q('musicurl') || q('playurl') || q('dataurl') || q('lowurl')
const sourceName = message.appMsgSourceName || q('sourcename')
const sourceDisplayName = q('sourcedisplayname') || ''
const appName = message.appMsgAppName || q('appname')
const sourceUsername = message.appMsgSourceUsername || q('sourceusername')
const finderName =
message.finderNickname ||
message.finderUsername ||
q('findernickname') ||
q('finder_nickname') ||
q('finderusername') ||
q('finder_username')
const lower = rawXml.toLowerCase()
const kind = message.appMsgKind || (
(xmlType === '2001' || lower.includes('hongbao')) ? 'red-packet'
: (xmlType === '115' ? 'gift'
: ((xmlType === '33' || xmlType === '36') ? 'miniapp'
: (((xmlType === '5' || xmlType === '49') && (sourceUsername.startsWith('gh_') || !!sourceName || appName.includes('公众号'))) ? 'official-link'
: (xmlType === '51' ? 'finder'
: (xmlType === '3' ? 'music'
: ((xmlType === '5' || xmlType === '49') ? 'link' // Fallback for standard links
: (!!musicUrl ? 'music' : '')))))))
)
if (!kind) return null
// 对视频号提取真实标题,避免出现 "当前版本不支持该内容"
let displayTitle = title
if (kind === 'finder' && (!displayTitle || displayTitle.includes('不支持'))) {
try {
const d = new DOMParser().parseFromString(rawXml, 'text/xml')
displayTitle = d.querySelector('finderFeed desc')?.textContent?.trim() || desc || ''
} catch {
displayTitle = desc || ''
}
}
const openExternal = (e: React.MouseEvent, nextUrl?: string) => {
if (!nextUrl) return
e.stopPropagation()
if (window.electronAPI?.shell?.openExternal) {
window.electronAPI.shell.openExternal(nextUrl)
} else {
window.open(nextUrl, '_blank')
}
}
const metaLabel =
kind === 'red-packet' ? '红包'
: kind === 'finder' ? (finderName || '视频号')
: kind === 'location' ? '位置'
: kind === 'music' ? (sourceName || appName || '音乐')
: (sourceName || appName || (sourceUsername.startsWith('gh_') ? '公众号' : ''))
const renderCard = (cardKind: string, clickableUrl?: string) => (
<div
className={`link-message appmsg-rich-card ${cardKind}`}
onClick={clickableUrl ? (e) => openExternal(e, clickableUrl) : undefined}
title={clickableUrl}
>
<div className="link-header">
<div className="link-title" title={title}>{title}</div>
{metaLabel ? <div className="appmsg-meta-badge">{metaLabel}</div> : null}
</div>
<div className="link-body">
<div className="link-desc-block">
{desc ? <div className="link-desc" title={desc}>{desc}</div> : null}
</div>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className={`link-thumb${((cardKind === 'miniapp') || /\.svg(?:$|\?)/i.test(thumbUrl)) ? ' theme-adaptive' : ''}`}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className={`link-thumb-placeholder ${cardKind}`}>{cardKind.slice(0, 2).toUpperCase()}</div>
)}
</div>
</div>
)
if (kind === 'red-packet') {
// 专属红包卡片
const greeting = (() => {
try {
const d = getDoc()
if (!d) return ''
return d.querySelector('receivertitle')?.textContent?.trim() ||
d.querySelector('sendertitle')?.textContent?.trim() || ''
} catch { return '' }
})()
return (
<div className="hongbao-message">
<div className="hongbao-icon">
<svg width="32" height="32" viewBox="0 0 40 40" fill="none">
<rect x="4" y="6" width="32" height="28" rx="4" fill="white" fillOpacity="0.3" />
<rect x="4" y="6" width="32" height="14" rx="4" fill="white" fillOpacity="0.2" />
<circle cx="20" cy="20" r="6" fill="white" fillOpacity="0.4" />
<text x="20" y="24" textAnchor="middle" fill="white" fontSize="12" fontWeight="bold">¥</text>
</svg>
</div>
<div className="hongbao-info">
<div className="hongbao-greeting">{greeting || '恭喜发财,大吉大利'}</div>
<div className="hongbao-label"></div>
</div>
</div>
)
}
if (kind === 'gift') {
// 礼物卡片
const giftImg = message.giftImageUrl || thumbUrl
const giftWish = message.giftWish || title || '送你一份心意'
const giftPriceRaw = message.giftPrice
const giftPriceYuan = giftPriceRaw ? (parseInt(giftPriceRaw) / 100).toFixed(2) : ''
return (
<div className="gift-message">
{giftImg && <img className="gift-img" src={giftImg} alt="" referrerPolicy="no-referrer" />}
<div className="gift-info">
<div className="gift-wish">{giftWish}</div>
{giftPriceYuan && <div className="gift-price">¥{giftPriceYuan}</div>}
<div className="gift-label"></div>
</div>
</div>
)
}
if (kind === 'finder') {
// 视频号专属卡片
const coverUrl = message.finderCoverUrl || thumbUrl
const duration = message.finderDuration
const authorName = finderName || ''
const authorAvatar = message.finderAvatar
const fmtDuration = duration ? `${Math.floor(duration / 60)}:${String(duration % 60).padStart(2, '0')}` : ''
return (
<div className="channel-video-card" onClick={url ? (e) => openExternal(e, url) : undefined}>
<div className="channel-video-cover">
{coverUrl ? (
<img src={coverUrl} alt="" referrerPolicy="no-referrer" />
) : (
<div className="channel-video-cover-placeholder">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</div>
)}
{fmtDuration && <span className="channel-video-duration">{fmtDuration}</span>}
</div>
<div className="channel-video-info">
<div className="channel-video-title">{displayTitle || '视频号视频'}</div>
<div className="channel-video-author">
{authorAvatar && <img className="channel-video-avatar" src={authorAvatar} alt="" referrerPolicy="no-referrer" />}
<span>{authorName || '视频号'}</span>
</div>
</div>
</div>
)
}
if (kind === 'music') {
// 音乐专属卡片
const albumUrl = message.musicAlbumUrl || thumbUrl
const playUrl = message.musicUrl || musicUrl || url
const songTitle = title || '未知歌曲'
const artist = desc || ''
const appLabel = sourceName || appName || ''
return (
<div className="music-message" onClick={playUrl ? (e) => openExternal(e, playUrl) : undefined}>
<div className="music-cover">
{albumUrl ? (
<img src={albumUrl} alt="" referrerPolicy="no-referrer" />
) : (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)}
</div>
<div className="music-info">
<div className="music-title">{songTitle}</div>
{artist && <div className="music-artist">{artist}</div>}
{appLabel && <div className="music-source">{appLabel}</div>}
</div>
</div>
)
}
if (kind === 'official-link') {
const authorAvatar = q('publisher > headimg') || q('brand_info > headimgurl') || q('appmsg > avatar') || q('headimgurl') || message.cardAvatarUrl
const authorName = sourceDisplayName || q('publisher > nickname') || sourceName || appName || '公众号'
const coverPic = q('mmreader > category > item > cover') || thumbUrl
const digest = q('mmreader > category > item > digest') || desc
const articleTitle = q('mmreader > category > item > title') || title
return (
<div className="official-message" onClick={url ? (e) => openExternal(e, url) : undefined}>
<div className="official-header">
{authorAvatar ? (
<img src={authorAvatar} alt="" className="official-avatar" referrerPolicy="no-referrer" />
) : (
<div className="official-avatar-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
)}
<span className="official-name">{authorName}</span>
</div>
<div className="official-body">
{coverPic ? (
<div className="official-cover-wrapper">
<img src={coverPic} alt="" className="official-cover" referrerPolicy="no-referrer" />
<div className="official-title-overlay">{articleTitle}</div>
</div>
) : (
<div className="official-title-text">{articleTitle}</div>
)}
{digest && <div className="official-digest">{digest}</div>}
</div>
</div>
)
}
if (kind === 'link') return renderCard('link', url || undefined)
if (kind === 'card') return renderCard('card', url || undefined)
if (kind === 'miniapp') {
return (
<div className="miniapp-message miniapp-message-rich">
<div className="miniapp-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
</svg>
</div>
<div className="miniapp-info">
<div className="miniapp-title">{title}</div>
<div className="miniapp-label">{metaLabel || '小程序'}</div>
</div>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className={`miniapp-thumb${/\.svg(?:$|\?)/i.test(thumbUrl) ? ' theme-adaptive' : ''}`}
loading="lazy"
referrerPolicy="no-referrer"
/>
) : null}
</div>
)
}
return null
})()
if (appMsgRichPreview) {
return appMsgRichPreview
}
const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg')) const isAppMsg = message.rawContent?.includes('<appmsg') || (message.parsedContent && message.parsedContent.includes('<appmsg'))
if (isAppMsg) { if (isAppMsg) {

View File

@@ -7,8 +7,8 @@
// 左侧联系人面板 // 左侧联系人面板
.contacts-panel { .contacts-panel {
width: 380px; width: 350px;
min-width: 380px; min-width: 350px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
@@ -55,6 +55,11 @@
.spin { .spin {
animation: contactsSpin 1s linear infinite; animation: contactsSpin 1s linear infinite;
} }
&.export-mode-btn.active {
background: var(--primary);
color: #fff;
}
} }
} }
@@ -110,11 +115,11 @@
} }
.type-filters { .type-filters {
display: flex; display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px; gap: 8px;
padding: 0 20px 16px; padding: 0 20px 16px;
flex-wrap: nowrap; max-width: 300px;
overflow-x: auto;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
@@ -174,6 +179,24 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
.selection-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 20px 12px;
.checkbox-item {
font-size: 13px;
color: var(--text-secondary);
}
.selection-count {
font-size: 12px;
color: var(--text-tertiary);
}
}
.loading-state, .loading-state,
.empty-state { .empty-state {
flex: 1; flex: 1;
@@ -213,12 +236,35 @@
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 10px;
transition: all 0.2s; transition: all 0.2s;
cursor: pointer;
margin-bottom: 4px; margin-bottom: 4px;
&:hover { &:hover {
background: var(--bg-hover); background: var(--bg-hover);
} }
&.selected {
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
&.active {
background: var(--bg-tertiary);
}
.contact-select {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--primary);
}
}
.contact-avatar { .contact-avatar {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -297,6 +343,94 @@
} }
} }
// 右侧详情面板内的联系人资料
.detail-profile {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border-color);
margin-bottom: 20px;
.detail-avatar {
width: 80px;
height: 80px;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
img { width: 100%; height: 100%; object-fit: cover; }
span { color: #fff; font-size: 28px; font-weight: 600; }
}
.detail-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
}
.detail-info-list {
margin-bottom: 24px;
.detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
font-size: 13px;
border-bottom: 1px solid var(--border-color);
&:last-child { border-bottom: none; }
}
.detail-label {
color: var(--text-tertiary);
min-width: 48px;
flex-shrink: 0;
}
.detail-value {
color: var(--text-primary);
word-break: break-all;
user-select: text;
}
}
.goto-chat-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: var(--primary);
color: #fff;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover { background: var(--primary-hover); }
}
.empty-detail {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-tertiary);
font-size: 14px;
}
// 右侧设置面板 // 右侧设置面板
.settings-panel { .settings-panel {
flex: 1; flex: 1;
@@ -548,4 +682,4 @@
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }

View File

@@ -1,5 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown } from 'lucide-react' import { useNavigate } from 'react-router-dom'
import { Search, RefreshCw, X, User, Users, MessageSquare, Loader2, FolderOpen, Download, ChevronDown, MessageCircle, UserX } from 'lucide-react'
import { useChatStore } from '../stores/chatStore'
import './ContactsPage.scss' import './ContactsPage.scss'
interface ContactInfo { interface ContactInfo {
@@ -8,20 +10,28 @@ interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
function ContactsPage() { function ContactsPage() {
const [contacts, setContacts] = useState<ContactInfo[]>([]) const [contacts, setContacts] = useState<ContactInfo[]>([])
const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([]) const [filteredContacts, setFilteredContacts] = useState<ContactInfo[]>([])
const [selectedUsernames, setSelectedUsernames] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [searchKeyword, setSearchKeyword] = useState('') const [searchKeyword, setSearchKeyword] = useState('')
const [contactTypes, setContactTypes] = useState({ const [contactTypes, setContactTypes] = useState({
friends: true, friends: true,
groups: true, groups: false,
officials: true officials: false,
deletedFriends: false
}) })
// 导出模式与查看详情
const [exportMode, setExportMode] = useState(false)
const [selectedContact, setSelectedContact] = useState<ContactInfo | null>(null)
const navigate = useNavigate()
const { setCurrentSession } = useChatStore()
// 导出相关状态 // 导出相关状态
const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json') const [exportFormat, setExportFormat] = useState<'json' | 'csv' | 'vcf'>('json')
const [exportAvatars, setExportAvatars] = useState(true) const [exportAvatars, setExportAvatars] = useState(true)
@@ -62,6 +72,7 @@ function ContactsPage() {
setContacts(contactsResult.contacts) setContacts(contactsResult.contacts)
setFilteredContacts(contactsResult.contacts) setFilteredContacts(contactsResult.contacts)
setSelectedUsernames(new Set())
} }
} catch (e) { } catch (e) {
console.error('加载通讯录失败:', e) console.error('加载通讯录失败:', e)
@@ -83,6 +94,7 @@ function ContactsPage() {
if (c.type === 'friend' && !contactTypes.friends) return false if (c.type === 'friend' && !contactTypes.friends) return false
if (c.type === 'group' && !contactTypes.groups) return false if (c.type === 'group' && !contactTypes.groups) return false
if (c.type === 'official' && !contactTypes.officials) return false if (c.type === 'official' && !contactTypes.officials) return false
if (c.type === 'former_friend' && !contactTypes.deletedFriends) return false
return true return true
}) })
@@ -111,6 +123,37 @@ function ContactsPage() {
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showFormatSelect]) }, [showFormatSelect])
const selectedInFilteredCount = filteredContacts.reduce((count, contact) => {
return selectedUsernames.has(contact.username) ? count + 1 : count
}, 0)
const allFilteredSelected = filteredContacts.length > 0 && selectedInFilteredCount === filteredContacts.length
const toggleContactSelected = (username: string, checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
if (checked) {
next.add(username)
} else {
next.delete(username)
}
return next
})
}
const toggleAllFilteredSelected = (checked: boolean) => {
setSelectedUsernames(prev => {
const next = new Set(prev)
filteredContacts.forEach(contact => {
if (checked) {
next.add(contact.username)
} else {
next.delete(contact.username)
}
})
return next
})
}
const getAvatarLetter = (name: string) => { const getAvatarLetter = (name: string) => {
if (!name) return '?' if (!name) return '?'
return [...name][0] || '?' return [...name][0] || '?'
@@ -121,6 +164,7 @@ function ContactsPage() {
case 'friend': return <User size={14} /> case 'friend': return <User size={14} />
case 'group': return <Users size={14} /> case 'group': return <Users size={14} />
case 'official': return <MessageSquare size={14} /> case 'official': return <MessageSquare size={14} />
case 'former_friend': return <UserX size={14} />
default: return <User size={14} /> default: return <User size={14} />
} }
} }
@@ -130,6 +174,7 @@ function ContactsPage() {
case 'friend': return '好友' case 'friend': return '好友'
case 'group': return '群聊' case 'group': return '群聊'
case 'official': return '公众号' case 'official': return '公众号'
case 'former_friend': return '曾经的好友'
default: return '其他' default: return '其他'
} }
} }
@@ -154,6 +199,10 @@ function ContactsPage() {
alert('请先选择导出位置') alert('请先选择导出位置')
return return
} }
if (selectedUsernames.size === 0) {
alert('请至少选择一个联系人')
return
}
setIsExporting(true) setIsExporting(true)
try { try {
@@ -164,7 +213,8 @@ function ContactsPage() {
friends: contactTypes.friends, friends: contactTypes.friends,
groups: contactTypes.groups, groups: contactTypes.groups,
officials: contactTypes.officials officials: contactTypes.officials
} },
selectedUsernames: Array.from(selectedUsernames)
} }
const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions) const result = await window.electronAPI.export.exportContacts(exportFolder, exportOptions)
@@ -198,9 +248,18 @@ function ContactsPage() {
<div className="contacts-panel"> <div className="contacts-panel">
<div className="panel-header"> <div className="panel-header">
<h2></h2> <h2></h2>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}> <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} /> <button
</button> className={`icon-btn export-mode-btn ${exportMode ? 'active' : ''}`}
onClick={() => { setExportMode(!exportMode); setSelectedContact(null) }}
title={exportMode ? '退出导出模式' : '进入导出模式'}
>
<Download size={18} />
</button>
<button className="icon-btn" onClick={loadContacts} disabled={isLoading}>
<RefreshCw size={18} className={isLoading ? 'spin' : ''} />
</button>
</div>
</div> </div>
<div className="search-bar"> <div className="search-bar">
@@ -220,31 +279,20 @@ function ContactsPage() {
<div className="type-filters"> <div className="type-filters">
<label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.friends ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.friends} onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })} />
type="checkbox" <User size={16} /><span></span>
checked={contactTypes.friends}
onChange={e => setContactTypes({ ...contactTypes, friends: e.target.checked })}
/>
<User size={16} />
<span></span>
</label> </label>
<label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.groups ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.groups} onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })} />
type="checkbox" <Users size={16} /><span></span>
checked={contactTypes.groups}
onChange={e => setContactTypes({ ...contactTypes, groups: e.target.checked })}
/>
<Users size={16} />
<span></span>
</label> </label>
<label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}> <label className={`filter-chip ${contactTypes.officials ? 'active' : ''}`}>
<input <input type="checkbox" checked={contactTypes.officials} onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} />
type="checkbox" <MessageSquare size={16} /><span></span>
checked={contactTypes.officials} </label>
onChange={e => setContactTypes({ ...contactTypes, officials: e.target.checked })} <label className={`filter-chip ${contactTypes.deletedFriends ? 'active' : ''}`}>
/> <input type="checkbox" checked={contactTypes.deletedFriends} onChange={e => setContactTypes({ ...contactTypes, deletedFriends: e.target.checked })} />
<MessageSquare size={16} /> <UserX size={16} /><span></span>
<span></span>
</label> </label>
</div> </div>
@@ -252,6 +300,21 @@ function ContactsPage() {
{filteredContacts.length} {filteredContacts.length}
</div> </div>
{exportMode && (
<div className="selection-toolbar">
<label className="checkbox-item">
<input
type="checkbox"
checked={allFilteredSelected}
onChange={e => toggleAllFilteredSelected(e.target.checked)}
disabled={filteredContacts.length === 0}
/>
<span></span>
</label>
<span className="selection-count"> {selectedUsernames.size} {selectedInFilteredCount} / {filteredContacts.length}</span>
</div>
)}
{isLoading ? ( {isLoading ? (
<div className="loading-state"> <div className="loading-state">
<Loader2 size={32} className="spin" /> <Loader2 size={32} className="spin" />
@@ -263,115 +326,177 @@ function ContactsPage() {
</div> </div>
) : ( ) : (
<div className="contacts-list"> <div className="contacts-list">
{filteredContacts.map(contact => ( {filteredContacts.map(contact => {
<div key={contact.username} className="contact-item"> const isChecked = selectedUsernames.has(contact.username)
<div className="contact-avatar"> const isActive = !exportMode && selectedContact?.username === contact.username
{contact.avatarUrl ? ( return (
<img src={contact.avatarUrl} alt="" /> <div
) : ( key={contact.username}
<span>{getAvatarLetter(contact.displayName)}</span> className={`contact-item ${exportMode && isChecked ? 'selected' : ''} ${isActive ? 'active' : ''}`}
onClick={() => {
if (exportMode) {
toggleContactSelected(contact.username, !isChecked)
} else {
setSelectedContact(isActive ? null : contact)
}
}}
>
{exportMode && (
<label className="contact-select" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={isChecked}
onChange={e => toggleContactSelected(contact.username, e.target.checked)}
/>
</label>
)} )}
<div className="contact-avatar">
{contact.avatarUrl ? (
<img src={contact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(contact.displayName)}</span>
)}
</div>
<div className="contact-info">
<div className="contact-name">{contact.displayName}</div>
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div> </div>
<div className="contact-info"> )
<div className="contact-name">{contact.displayName}</div> })}
{contact.remark && contact.remark !== contact.displayName && (
<div className="contact-remark">: {contact.remark}</div>
)}
</div>
<div className={`contact-type ${contact.type}`}>
{getContactTypeIcon(contact.type)}
<span>{getContactTypeName(contact.type)}</span>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
{/* 右侧:导出设置 */} {/* 右侧面板 */}
<div className="settings-panel"> {exportMode ? (
<div className="panel-header"> <div className="settings-panel">
<h2></h2> <div className="panel-header">
</div> <h2></h2>
</div>
<div className="settings-content"> <div className="settings-content">
<div className="setting-section"> <div className="setting-section">
<h3></h3> <h3></h3>
<div className="format-select" ref={formatDropdownRef}> <div className="format-select" ref={formatDropdownRef}>
<button <button
type="button" type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`} className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => setShowFormatSelect(!showFormatSelect)} onClick={() => setShowFormatSelect(!showFormatSelect)}
> >
<span className="select-value">{getOptionLabel(exportFormat)}</span> <span className="select-value">{getOptionLabel(exportFormat)}</span>
<ChevronDown size={16} /> <ChevronDown size={16} />
</button>
{showFormatSelect && (
<div className="select-dropdown">
{exportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${exportFormat === option.value ? 'active' : ''}`}
onClick={() => {
setExportFormat(option.value as 'json' | 'csv' | 'vcf')
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="setting-section">
<h3></h3>
<label className="checkbox-item">
<input type="checkbox" checked={exportAvatars} onChange={e => setExportAvatars(e.target.checked)} />
<span></span>
</label>
</div>
<div className="setting-section">
<h3></h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button className="select-folder-btn" onClick={selectExportFolder}>
<FolderOpen size={16} />
<span></span>
</button> </button>
{showFormatSelect && ( </div>
<div className="select-dropdown"> </div>
{exportFormatOptions.map(option => (
<button <div className="export-action">
key={option.value} <button
type="button" className="export-btn"
className={`select-option ${exportFormat === option.value ? 'active' : ''}`} onClick={startExport}
onClick={() => { disabled={!exportFolder || isExporting || selectedUsernames.size === 0}
setExportFormat(option.value as 'json' | 'csv' | 'vcf') >
setShowFormatSelect(false) {isExporting ? (
}} <><Loader2 size={18} className="spin" /><span>...</span></>
> ) : (
<span className="option-label">{option.label}</span> <><Download size={18} /><span></span></>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)} )}
</div>
</div>
<div className="setting-section">
<h3></h3>
<label className="checkbox-item">
<input
type="checkbox"
checked={exportAvatars}
onChange={e => setExportAvatars(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="setting-section">
<h3></h3>
<div className="export-path-display">
<FolderOpen size={16} />
<span>{exportFolder || '未设置'}</span>
</div>
<button className="select-folder-btn" onClick={selectExportFolder}>
<FolderOpen size={16} />
<span></span>
</button> </button>
</div> </div>
</div> </div>
) : selectedContact ? (
<div className="settings-panel">
<div className="panel-header">
<h2></h2>
</div>
<div className="settings-content">
<div className="detail-profile">
<div className="detail-avatar">
{selectedContact.avatarUrl ? (
<img src={selectedContact.avatarUrl} alt="" />
) : (
<span>{getAvatarLetter(selectedContact.displayName)}</span>
)}
</div>
<div className="detail-name">{selectedContact.displayName}</div>
<div className={`contact-type ${selectedContact.type}`}>
{getContactTypeIcon(selectedContact.type)}
<span>{getContactTypeName(selectedContact.type)}</span>
</div>
</div>
<div className="export-action"> <div className="detail-info-list">
<button <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.username}</span></div>
className="export-btn" <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.nickname || selectedContact.displayName}</span></div>
onClick={startExport} {selectedContact.remark && <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{selectedContact.remark}</span></div>}
disabled={!exportFolder || isExporting} <div className="detail-row"><span className="detail-label"></span><span className="detail-value">{getContactTypeName(selectedContact.type)}</span></div>
> </div>
{isExporting ? (
<> <button
<Loader2 size={18} className="spin" /> className="goto-chat-btn"
<span>...</span> onClick={() => {
</> setCurrentSession(selectedContact.username)
) : ( navigate('/chat')
<> }}
<Download size={18} /> >
<span></span> <MessageCircle size={18} />
</> <span></span>
)} </button>
</button> </div>
</div> </div>
</div> ) : (
<div className="settings-panel">
<div className="empty-detail">
<User size={48} />
<span></span>
</div>
</div>
)}
</div> </div>
) )
} }

View File

@@ -955,6 +955,18 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
} }
} }
@@ -1015,6 +1027,70 @@
} }
} }
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.calendar-nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
.date-picker-actions { .date-picker-actions {
display: flex; display: flex;
gap: 12px; gap: 12px;

View File

@@ -53,6 +53,7 @@ function ExportPage() {
const [showDatePicker, setShowDatePicker] = useState(false) const [showDatePicker, setShowDatePicker] = useState(false)
const [calendarDate, setCalendarDate] = useState(new Date()) const [calendarDate, setCalendarDate] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true) const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false) const [showMediaLayoutPrompt, setShowMediaLayoutPrompt] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false) const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [showPreExportDialog, setShowPreExportDialog] = useState(false) const [showPreExportDialog, setShowPreExportDialog] = useState(false)
@@ -66,6 +67,7 @@ function ExportPage() {
const [elapsedSeconds, setElapsedSeconds] = useState(0) const [elapsedSeconds, setElapsedSeconds] = useState(0)
const displayNameDropdownRef = useRef<HTMLDivElement>(null) const displayNameDropdownRef = useRef<HTMLDivElement>(null)
const preselectAppliedRef = useRef(false) const preselectAppliedRef = useRef(false)
const statsRequestIdRef = useRef(0)
const preselectSessionIds = useMemo(() => { const preselectSessionIds = useMemo(() => {
const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null const state = location.state as { preselectSessionIds?: unknown; preselectSessionId?: unknown } | null
@@ -382,7 +384,9 @@ function ExportPage() {
if (selectedSessions.size === 0 || !exportFolder) return if (selectedSessions.size === 0 || !exportFolder) return
// 先获取预估统计 // 先获取预估统计
const requestId = ++statsRequestIdRef.current
setIsLoadingStats(true) setIsLoadingStats(true)
setPreExportStats(null)
setShowPreExportDialog(true) setShowPreExportDialog(true)
try { try {
const sessionList = Array.from(selectedSessions) const sessionList = Array.from(selectedSessions)
@@ -400,16 +404,21 @@ function ExportPage() {
} : null } : null
} }
const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions) const stats = await window.electronAPI.export.getExportStats(sessionList, exportOptions)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(stats) setPreExportStats(stats)
} catch (e) { } catch (e) {
console.error('获取导出统计失败:', e) console.error('获取导出统计失败:', e)
if (statsRequestIdRef.current !== requestId) return
setPreExportStats(null) setPreExportStats(null)
} finally { } finally {
if (statsRequestIdRef.current !== requestId) return
setIsLoadingStats(false) setIsLoadingStats(false)
} }
} }
const confirmExport = () => { const confirmExport = () => {
statsRequestIdRef.current++
setIsLoadingStats(false)
setShowPreExportDialog(false) setShowPreExportDialog(false)
setPreExportStats(null) setPreExportStats(null)
@@ -911,7 +920,7 @@ function ExportPage() {
{isLoadingStats ? ( {isLoadingStats ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '24px 0', justifyContent: 'center' }}>
<Loader2 size={20} className="spin" /> <Loader2 size={20} className="spin" />
<span style={{ fontSize: 14, color: 'var(--text-secondary)' }}>...</span> <span style={{ fontSize: 14, color: 'var(--text-secondary)' }}></span>
</div> </div>
) : preExportStats ? ( ) : preExportStats ? (
<div style={{ padding: '12px 0' }}> <div style={{ padding: '12px 0' }}>
@@ -957,11 +966,11 @@ function ExportPage() {
<p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}></p> <p style={{ fontSize: 14, color: 'var(--text-secondary)', padding: '16px 0' }}></p>
)} )}
<div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}> <div className="layout-actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 8 }}>
<button className="layout-cancel-btn" onClick={() => { setShowPreExportDialog(false); setPreExportStats(null) }}> <button className="layout-cancel-btn" onClick={() => { statsRequestIdRef.current++; setIsLoadingStats(false); setShowPreExportDialog(false); setPreExportStats(null) }}>
</button> </button>
<button className="layout-option-btn primary" onClick={confirmExport} disabled={isLoadingStats}> <button className="layout-option-btn primary" onClick={confirmExport}>
<span className="layout-title"></span> <span className="layout-title">{isLoadingStats ? '直接导出' : '开始导出'}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1039,7 +1048,7 @@ function ExportPage() {
{/* 日期选择弹窗 */} {/* 日期选择弹窗 */}
{showDatePicker && ( {showDatePicker && (
<div className="export-overlay" onClick={() => setShowDatePicker(false)}> <div className="export-overlay" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
<div className="date-picker-modal" onClick={e => e.stopPropagation()}> <div className="date-picker-modal" onClick={e => e.stopPropagation()}>
<h3></h3> <h3></h3>
<p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}> <p style={{ fontSize: '13px', color: 'var(--text-secondary)', margin: '8px 0 16px 0' }}>
@@ -1114,7 +1123,7 @@ function ExportPage() {
> >
<ChevronLeft size={18} /> <ChevronLeft size={18} />
</button> </button>
<span className="calendar-month"> <span className="calendar-month clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{calendarDate.getFullYear()}{calendarDate.getMonth() + 1} {calendarDate.getFullYear()}{calendarDate.getMonth() + 1}
</span> </span>
<button <button
@@ -1124,6 +1133,32 @@ function ExportPage() {
<ChevronRight size={18} /> <ChevronRight size={18} />
</button> </button>
</div> </div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))}>
<ChevronLeft size={16} />
</button>
<span className="year-label">{calendarDate.getFullYear()}</span>
<button className="calendar-nav-btn" onClick={() => setCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))}>
<ChevronRight size={16} />
</button>
</div>
<div className="month-grid">
{['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'].map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
setCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : (
<>
<div className="calendar-weekdays"> <div className="calendar-weekdays">
{['日', '一', '二', '三', '四', '五', '六'].map(day => ( {['日', '一', '二', '三', '四', '五', '六'].map(day => (
<div key={day} className="calendar-weekday">{day}</div> <div key={day} className="calendar-weekday">{day}</div>
@@ -1155,12 +1190,14 @@ function ExportPage() {
) )
})} })}
</div> </div>
</>
)}
</div> </div>
<div className="date-picker-actions"> <div className="date-picker-actions">
<button className="cancel-btn" onClick={() => setShowDatePicker(false)}> <button className="cancel-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
</button> </button>
<button className="confirm-btn" onClick={() => setShowDatePicker(false)}> <button className="confirm-btn" onClick={() => { setShowDatePicker(false); setShowYearMonthPicker(false) }}>
</button> </button>
</div> </div>

View File

@@ -777,6 +777,344 @@
} }
} }
.member-export-panel {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
.member-export-empty {
padding: 20px;
border-radius: 12px;
background: var(--bg-tertiary);
color: var(--text-secondary);
text-align: center;
font-size: 14px;
}
.member-export-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.member-export-field {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
> span {
font-size: 12px;
color: var(--text-secondary);
}
}
.select-trigger {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 9999px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
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);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 30;
max-height: 280px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 13px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.member-select-trigger-value {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.member-select-dropdown {
padding: 8px;
}
.member-select-search {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 7px 9px;
margin-bottom: 8px;
background: var(--bg-tertiary);
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
color: var(--text-primary);
font-size: 12px;
}
}
.member-select-options {
display: flex;
flex-direction: column;
gap: 4px;
}
.member-select-empty {
padding: 10px 8px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
}
.member-select-option {
display: grid;
grid-template-columns: 28px 1fr;
gap: 8px;
align-items: center;
padding: 8px 10px;
.member-option-main {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-option-meta {
grid-column: 2 / 3;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.active {
.member-option-main,
.member-option-meta {
color: var(--primary);
}
}
}
.member-export-folder {
grid-column: 1 / -1;
}
.member-export-folder-row {
display: flex;
gap: 8px;
input {
flex: 1;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 13px;
padding: 8px 10px;
outline: none;
}
button {
border: none;
border-radius: 10px;
background: var(--bg-tertiary);
color: var(--text-primary);
padding: 0 12px;
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
}
}
}
.member-export-options {
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 14px;
background: rgba(255, 255, 255, 0.05);
display: flex;
flex-direction: column;
gap: 12px;
}
.member-export-chip-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.chip-group-label {
font-size: 12px;
color: var(--text-secondary);
}
.member-export-chip-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.export-filter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 10px;
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
border-color: var(--text-tertiary);
color: var(--text-primary);
transform: translateY(-1px);
}
&.active {
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
&.disabled,
&:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
&:hover {
background: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-secondary);
}
}
}
.member-export-actions {
display: flex;
justify-content: flex-end;
}
.member-export-start-btn {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 10px;
background: var(--primary);
color: #fff;
padding: 10px 16px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
.rankings-list { .rankings-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, User, Medal, Search, X, ChevronLeft, Copy, Check, Download } from 'lucide-react' import { Users, BarChart3, Clock, Image, Loader2, RefreshCw, Medal, Search, X, ChevronLeft, Copy, Check, Download, ChevronDown } 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'
import * as configService from '../services/config'
import './GroupAnalyticsPage.scss' import './GroupAnalyticsPage.scss'
interface GroupChatInfo { interface GroupChatInfo {
@@ -28,7 +29,26 @@ interface GroupMessageRank {
messageCount: number messageCount: number
} }
type AnalysisFunction = 'members' | 'ranking' | 'activeHours' | 'mediaStats' type AnalysisFunction = 'members' | 'memberExport' | 'ranking' | 'activeHours' | 'mediaStats'
type MemberExportFormat = 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone'
interface MemberMessageExportOptions {
format: MemberExportFormat
exportAvatars: boolean
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
displayNamePreference: 'group-nickname' | 'remark' | 'nickname'
}
interface MemberExportFormatOption {
value: MemberExportFormat
label: string
desc: string
}
function GroupAnalyticsPage() { function GroupAnalyticsPage() {
const location = useLocation() const location = useLocation()
@@ -46,10 +66,31 @@ function GroupAnalyticsPage() {
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 [isExportingMembers, setIsExportingMembers] = useState(false)
const [isExportingMemberMessages, setIsExportingMemberMessages] = useState(false)
const [selectedExportMemberUsername, setSelectedExportMemberUsername] = useState('')
const [exportFolder, setExportFolder] = useState('')
const [memberExportOptions, setMemberExportOptions] = useState<MemberMessageExportOptions>({
format: 'excel',
exportAvatars: true,
exportMedia: false,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: false,
displayNamePreference: 'remark'
})
// 成员详情弹框 // 成员详情弹框
const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null) const [selectedMember, setSelectedMember] = useState<GroupMember | null>(null)
const [copiedField, setCopiedField] = useState<string | null>(null) const [copiedField, setCopiedField] = useState<string | null>(null)
const [showMemberSelect, setShowMemberSelect] = useState(false)
const [showFormatSelect, setShowFormatSelect] = useState(false)
const [showDisplayNameSelect, setShowDisplayNameSelect] = useState(false)
const [memberSearchKeyword, setMemberSearchKeyword] = useState('')
const memberSelectDropdownRef = useRef<HTMLDivElement>(null)
const formatDropdownRef = useRef<HTMLDivElement>(null)
const displayNameDropdownRef = useRef<HTMLDivElement>(null)
// 时间范围 // 时间范围
const [startDate, setStartDate] = useState<string>('') const [startDate, setStartDate] = useState<string>('')
@@ -74,9 +115,84 @@ function GroupAnalyticsPage() {
.filter(Boolean) .filter(Boolean)
}, [location.state]) }, [location.state])
const memberExportFormatOptions = useMemo<MemberExportFormatOption[]>(() => ([
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式CSV' }
]), [])
const displayNameOptions = useMemo<Array<{
value: MemberMessageExportOptions['displayNamePreference']
label: string
desc: string
}>>(() => ([
{ value: 'group-nickname', label: '群昵称优先', desc: '仅群聊有效,私聊显示备注/昵称' },
{ value: 'remark', label: '备注优先', desc: '有备注显示备注,否则显示昵称' },
{ value: 'nickname', label: '微信昵称', desc: '始终显示微信昵称' }
]), [])
const selectedExportMember = useMemo(
() => members.find(member => member.username === selectedExportMemberUsername) || null,
[members, selectedExportMemberUsername]
)
const selectedFormatOption = useMemo(
() => memberExportFormatOptions.find(option => option.value === memberExportOptions.format) || memberExportFormatOptions[0],
[memberExportFormatOptions, memberExportOptions.format]
)
const selectedDisplayNameOption = useMemo(
() => displayNameOptions.find(option => option.value === memberExportOptions.displayNamePreference) || displayNameOptions[0],
[displayNameOptions, memberExportOptions.displayNamePreference]
)
const filteredMemberOptions = useMemo(() => {
const keyword = memberSearchKeyword.trim().toLowerCase()
if (!keyword) return members
return members.filter(member => {
const fields = [
member.username,
member.displayName,
member.nickname,
member.remark,
member.alias
]
return fields.some(field => String(field || '').toLowerCase().includes(keyword))
})
}, [memberSearchKeyword, members])
const loadExportPath = useCallback(async () => {
try {
const savedPath = await configService.getExportPath()
if (savedPath) {
setExportFolder(savedPath)
return
}
const downloadsPath = await window.electronAPI.app.getDownloadsPath()
setExportFolder(downloadsPath)
} catch (e) {
console.error('加载导出路径失败:', e)
}
}, [])
const loadGroups = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => { useEffect(() => {
loadGroups() loadGroups()
}, []) loadExportPath()
}, [loadGroups, loadExportPath])
useEffect(() => { useEffect(() => {
preselectAppliedRef.current = false preselectAppliedRef.current = false
@@ -90,6 +206,34 @@ function GroupAnalyticsPage() {
} }
}, [searchQuery, groups]) }, [searchQuery, groups])
useEffect(() => {
if (members.length === 0) {
setSelectedExportMemberUsername('')
return
}
const exists = members.some(member => member.username === selectedExportMemberUsername)
if (!exists) {
setSelectedExportMemberUsername(members[0].username)
}
}, [members, selectedExportMemberUsername])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (showMemberSelect && memberSelectDropdownRef.current && !memberSelectDropdownRef.current.contains(target)) {
setShowMemberSelect(false)
}
if (showFormatSelect && formatDropdownRef.current && !formatDropdownRef.current.contains(target)) {
setShowFormatSelect(false)
}
if (showDisplayNameSelect && displayNameDropdownRef.current && !displayNameDropdownRef.current.contains(target)) {
setShowDisplayNameSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showDisplayNameSelect, showFormatSelect, showMemberSelect])
useEffect(() => { useEffect(() => {
if (preselectAppliedRef.current) return if (preselectAppliedRef.current) return
if (groups.length === 0 || preselectGroupIds.length === 0) return if (groups.length === 0 || preselectGroupIds.length === 0) return
@@ -125,27 +269,12 @@ function GroupAnalyticsPage() {
// 日期范围变化时自动刷新 // 日期范围变化时自动刷新
useEffect(() => { useEffect(() => {
if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members') { if (dateRangeReady && selectedGroup && selectedFunction && selectedFunction !== 'members' && selectedFunction !== 'memberExport') {
setDateRangeReady(false) setDateRangeReady(false)
loadFunctionData(selectedFunction) loadFunctionData(selectedFunction)
} }
}, [dateRangeReady]) }, [dateRangeReady])
const loadGroups = useCallback(async () => {
setIsLoading(true)
try {
const result = await window.electronAPI.groupAnalytics.getGroupChats()
if (result.success && result.data) {
setGroups(result.data)
setFilteredGroups(result.data)
}
} catch (e) {
console.error(e)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => { useEffect(() => {
const handleChange = () => { const handleChange = () => {
setGroups([]) setGroups([])
@@ -157,15 +286,21 @@ function GroupAnalyticsPage() {
setActiveHours({}) setActiveHours({})
setMediaStats(null) setMediaStats(null)
void loadGroups() void loadGroups()
void loadExportPath()
} }
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)
}, [loadGroups]) }, [loadExportPath, loadGroups])
const handleGroupSelect = (group: GroupChatInfo) => { const handleGroupSelect = (group: GroupChatInfo) => {
if (selectedGroup?.username !== group.username) { if (selectedGroup?.username !== group.username) {
setSelectedGroup(group) setSelectedGroup(group)
setSelectedFunction(null) setSelectedFunction(null)
setSelectedExportMemberUsername('')
setMemberSearchKeyword('')
setShowMemberSelect(false)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
} }
} }
@@ -191,6 +326,11 @@ function GroupAnalyticsPage() {
if (result.success && result.data) setMembers(result.data) if (result.success && result.data) setMembers(result.data)
break break
} }
case 'memberExport': {
const result = await window.electronAPI.groupAnalytics.getGroupMembers(selectedGroup.username)
if (result.success && result.data) setMembers(result.data)
break
}
case 'ranking': { case 'ranking': {
const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime) const result = await window.electronAPI.groupAnalytics.getGroupMessageRanking(selectedGroup.username, 20, startTime, endTime)
if (result.success && result.data) setRankings(result.data) if (result.success && result.data) setRankings(result.data)
@@ -286,6 +426,7 @@ function GroupAnalyticsPage() {
} }
const handleDateRangeComplete = () => { const handleDateRangeComplete = () => {
if (selectedFunction === 'memberExport') return
setDateRangeReady(true) setDateRangeReady(true)
} }
@@ -323,6 +464,86 @@ function GroupAnalyticsPage() {
} }
} }
const handleMemberExportFormatChange = (format: MemberExportFormat) => {
setMemberExportOptions(prev => {
const next = { ...prev, format }
if (format === 'html') {
return {
...next,
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true
}
}
return next
})
}
const handleChooseExportFolder = async () => {
try {
const result = await window.electronAPI.dialog.openDirectory({
title: '选择导出目录'
})
if (!result.canceled && result.filePaths.length > 0) {
setExportFolder(result.filePaths[0])
await configService.setExportPath(result.filePaths[0])
}
} catch (e) {
console.error('选择导出目录失败:', e)
alert(`选择导出目录失败:${String(e)}`)
}
}
const handleExportMemberMessages = async () => {
if (!selectedGroup || !selectedExportMemberUsername || !exportFolder || isExportingMemberMessages) return
const member = members.find(item => item.username === selectedExportMemberUsername)
if (!member) {
alert('请先选择成员')
return
}
setIsExportingMemberMessages(true)
try {
const hasDateRange = Boolean(startDate && endDate)
const result = await window.electronAPI.export.exportSessions(
[selectedGroup.username],
exportFolder,
{
format: memberExportOptions.format,
dateRange: hasDateRange
? {
start: Math.floor(new Date(startDate).getTime() / 1000),
end: Math.floor(new Date(`${endDate}T23:59:59`).getTime() / 1000)
}
: null,
exportAvatars: memberExportOptions.exportAvatars,
exportMedia: memberExportOptions.exportMedia,
exportImages: memberExportOptions.exportMedia && memberExportOptions.exportImages,
exportVoices: memberExportOptions.exportMedia && memberExportOptions.exportVoices,
exportVideos: memberExportOptions.exportMedia && memberExportOptions.exportVideos,
exportEmojis: memberExportOptions.exportMedia && memberExportOptions.exportEmojis,
exportVoiceAsText: memberExportOptions.exportVoiceAsText,
sessionLayout: memberExportOptions.exportMedia ? 'per-session' : 'shared',
displayNamePreference: memberExportOptions.displayNamePreference,
senderUsername: member.username,
fileNameSuffix: sanitizeFileName(member.displayName || member.username)
}
)
if (result.success && (result.successCount ?? 0) > 0) {
alert(`导出成功:${member.displayName || member.username}`)
} else {
alert(`导出失败:${result.error || '未知错误'}`)
}
} catch (e) {
console.error('导出成员消息失败:', e)
alert(`导出失败:${String(e)}`)
} finally {
setIsExportingMemberMessages(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)
@@ -479,6 +700,10 @@ function GroupAnalyticsPage() {
<Users size={32} /> <Users size={32} />
<span></span> <span></span>
</div> </div>
<div className="function-card" onClick={() => handleFunctionSelect('memberExport')}>
<Download size={32} />
<span></span>
</div>
<div className="function-card" onClick={() => handleFunctionSelect('ranking')}> <div className="function-card" onClick={() => handleFunctionSelect('ranking')}>
<BarChart3 size={32} /> <BarChart3 size={32} />
<span></span> <span></span>
@@ -499,6 +724,7 @@ function GroupAnalyticsPage() {
const getFunctionTitle = () => { const getFunctionTitle = () => {
switch (selectedFunction) { switch (selectedFunction) {
case 'members': return '群成员查看' case 'members': return '群成员查看'
case 'memberExport': return '成员消息导出'
case 'ranking': return '群聊发言排行' case 'ranking': return '群聊发言排行'
case 'activeHours': return '群聊活跃时段' case 'activeHours': return '群聊活跃时段'
case 'mediaStats': return '媒体内容统计' case 'mediaStats': return '媒体内容统计'
@@ -554,6 +780,234 @@ function GroupAnalyticsPage() {
))} ))}
</div> </div>
)} )}
{selectedFunction === 'memberExport' && (
<div className="member-export-panel">
{members.length === 0 ? (
<div className="member-export-empty"></div>
) : (
<>
<div className="member-export-grid">
<div className="member-export-field" ref={memberSelectDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showMemberSelect ? 'open' : ''}`}
onClick={() => {
setShowMemberSelect(prev => !prev)
setShowFormatSelect(false)
setShowDisplayNameSelect(false)
}}
>
<div className="member-select-trigger-value">
<Avatar
src={selectedExportMember?.avatarUrl}
name={selectedExportMember?.displayName || selectedExportMember?.username || '?'}
size={24}
/>
<span className="select-value">{selectedExportMember?.displayName || selectedExportMember?.username || '请选择成员'}</span>
</div>
<ChevronDown size={16} />
</button>
{showMemberSelect && (
<div className="select-dropdown member-select-dropdown">
<div className="member-select-search">
<Search size={14} />
<input
type="text"
value={memberSearchKeyword}
onChange={e => setMemberSearchKeyword(e.target.value)}
placeholder="搜索 wxid / 昵称 / 备注 / 微信号"
/>
</div>
<div className="member-select-options">
{filteredMemberOptions.length === 0 ? (
<div className="member-select-empty"></div>
) : (
filteredMemberOptions.map(member => (
<button
key={member.username}
type="button"
className={`select-option member-select-option ${selectedExportMemberUsername === member.username ? 'active' : ''}`}
onClick={() => {
setSelectedExportMemberUsername(member.username)
setShowMemberSelect(false)
}}
>
<Avatar src={member.avatarUrl} name={member.displayName} size={28} />
<span className="member-option-main">{member.displayName || member.username}</span>
<span className="member-option-meta">
wxid: {member.username}
{member.alias ? ` · 微信号: ${member.alias}` : ''}
{member.remark ? ` · 备注: ${member.remark}` : ''}
{member.nickname ? ` · 昵称: ${member.nickname}` : ''}
</span>
</button>
))
)}
</div>
</div>
)}
</div>
<div className="member-export-field" ref={formatDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showFormatSelect ? 'open' : ''}`}
onClick={() => {
setShowFormatSelect(prev => !prev)
setShowMemberSelect(false)
setShowDisplayNameSelect(false)
}}
>
<span className="select-value">{selectedFormatOption.label}</span>
<ChevronDown size={16} />
</button>
{showFormatSelect && (
<div className="select-dropdown">
{memberExportFormatOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${memberExportOptions.format === option.value ? 'active' : ''}`}
onClick={() => {
handleMemberExportFormatChange(option.value)
setShowFormatSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
<div className="member-export-field member-export-folder">
<span></span>
<div className="member-export-folder-row">
<input value={exportFolder} readOnly placeholder="请选择导出目录" />
<button type="button" onClick={handleChooseExportFolder}>
</button>
</div>
</div>
</div>
<div className="member-export-options">
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportMedia ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportMedia: !prev.exportMedia }))}
>
</button>
</div>
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<div className="member-export-chip-list">
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportImages ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportImages: !prev.exportImages }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVoices ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoices: !prev.exportVoices }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVideos ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVideos: !prev.exportVideos }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportEmojis ? 'active' : ''} ${!memberExportOptions.exportMedia ? 'disabled' : ''}`}
disabled={!memberExportOptions.exportMedia}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportEmojis: !prev.exportEmojis }))}
>
</button>
</div>
</div>
<div className="member-export-chip-group">
<span className="chip-group-label"></span>
<div className="member-export-chip-list">
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportVoiceAsText ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportVoiceAsText: !prev.exportVoiceAsText }))}
>
</button>
<button
type="button"
className={`export-filter-chip ${memberExportOptions.exportAvatars ? 'active' : ''}`}
onClick={() => setMemberExportOptions(prev => ({ ...prev, exportAvatars: !prev.exportAvatars }))}
>
</button>
</div>
</div>
<div className="member-export-field" ref={displayNameDropdownRef}>
<span></span>
<button
type="button"
className={`select-trigger ${showDisplayNameSelect ? 'open' : ''}`}
onClick={() => {
setShowDisplayNameSelect(prev => !prev)
setShowMemberSelect(false)
setShowFormatSelect(false)
}}
>
<span className="select-value">{selectedDisplayNameOption.label}</span>
<ChevronDown size={16} />
</button>
{showDisplayNameSelect && (
<div className="select-dropdown">
{displayNameOptions.map(option => (
<button
key={option.value}
type="button"
className={`select-option ${memberExportOptions.displayNamePreference === option.value ? 'active' : ''}`}
onClick={() => {
setMemberExportOptions(prev => ({ ...prev, displayNamePreference: option.value }))
setShowDisplayNameSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>
)}
</div>
</div>
<div className="member-export-actions">
<button
className="member-export-start-btn"
onClick={handleExportMemberMessages}
disabled={isExportingMemberMessages || !selectedExportMemberUsername || !exportFolder}
>
{isExportingMemberMessages ? <Loader2 size={16} className="spin" /> : <Download size={16} />}
<span>{isExportingMemberMessages ? '导出中...' : '开始导出'}</span>
</button>
</div>
</>
)}
</div>
)}
{selectedFunction === 'ranking' && ( {selectedFunction === 'ranking' && (
<div className="rankings-list"> <div className="rankings-list">
{rankings.map((item, index) => ( {rankings.map((item, index) => (

View File

@@ -46,6 +46,18 @@
background: var(--bg-tertiary); background: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
} }
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn {
&.active {
background: rgba(var(--primary-rgb, 76, 132, 255), 0.16);
color: var(--primary, #4c84ff);
}
}
} }
.scale-text { .scale-text {
@@ -78,14 +90,40 @@
cursor: grabbing; cursor: grabbing;
} }
img { .media-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
will-change: transform;
}
img,
video {
display: block;
max-width: none; max-width: none;
max-height: none; max-height: none;
object-fit: contain; object-fit: contain;
will-change: transform;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
pointer-events: auto; pointer-events: auto;
} }
.live-video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
opacity: 0;
will-change: opacity;
transition: opacity 0.3s ease-in-out;
}
.live-video.visible {
opacity: 1;
}
} }
} }

View File

@@ -1,18 +1,26 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react' import { ZoomIn, ZoomOut, RotateCw, RotateCcw } from 'lucide-react'
import { LivePhotoIcon } from '../components/LivePhotoIcon'
import './ImageWindow.scss' import './ImageWindow.scss'
export default function ImageWindow() { export default function ImageWindow() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const imagePath = searchParams.get('imagePath') const imagePath = searchParams.get('imagePath')
const liveVideoPath = searchParams.get('liveVideoPath')
const hasLiveVideo = !!liveVideoPath
const [isPlayingLive, setIsPlayingLive] = useState(false)
const [isVideoVisible, setIsVideoVisible] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const liveCleanupTimerRef = useRef<number | null>(null)
const [scale, setScale] = useState(1) const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0) const [rotation, setRotation] = useState(0)
const [position, setPosition] = useState({ x: 0, y: 0 }) const [position, setPosition] = useState({ x: 0, y: 0 })
const [initialScale, setInitialScale] = useState(1) const [initialScale, setInitialScale] = useState(1)
const viewportRef = useRef<HTMLDivElement>(null) const viewportRef = useRef<HTMLDivElement>(null)
// 使用 ref 存储拖动状态,避免闭包问题 // 使用 ref 存储拖动状态,避免闭包问题
const dragStateRef = useRef({ const dragStateRef = useRef({
isDragging: false, isDragging: false,
@@ -22,11 +30,49 @@ export default function ImageWindow() {
startPosY: 0 startPosY: 0
}) })
const clearLiveCleanupTimer = useCallback(() => {
if (liveCleanupTimerRef.current !== null) {
window.clearTimeout(liveCleanupTimerRef.current)
liveCleanupTimerRef.current = null
}
}, [])
const stopLivePlayback = useCallback((immediate = false) => {
clearLiveCleanupTimer()
setIsVideoVisible(false)
if (immediate) {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
return
}
liveCleanupTimerRef.current = window.setTimeout(() => {
if (videoRef.current) {
videoRef.current.pause()
videoRef.current.currentTime = 0
}
setIsPlayingLive(false)
liveCleanupTimerRef.current = null
}, 300)
}, [clearLiveCleanupTimer])
const handlePlayLiveVideo = useCallback(() => {
if (!liveVideoPath || isPlayingLive) return
clearLiveCleanupTimer()
setIsPlayingLive(true)
setIsVideoVisible(false)
}, [clearLiveCleanupTimer, liveVideoPath, isPlayingLive])
const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10)) const handleZoomIn = () => setScale(prev => Math.min(prev + 0.25, 10))
const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1)) const handleZoomOut = () => setScale(prev => Math.max(prev - 0.25, 0.1))
const handleRotate = () => setRotation(prev => (prev + 90) % 360) const handleRotate = () => setRotation(prev => (prev + 90) % 360)
const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360) const handleRotateCcw = () => setRotation(prev => (prev - 90 + 360) % 360)
// 重置视图 // 重置视图
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
setScale(1) setScale(1)
@@ -39,7 +85,7 @@ export default function ImageWindow() {
const img = e.currentTarget const img = e.currentTarget
const naturalWidth = img.naturalWidth const naturalWidth = img.naturalWidth
const naturalHeight = img.naturalHeight const naturalHeight = img.naturalHeight
if (viewportRef.current) { if (viewportRef.current) {
const viewportWidth = viewportRef.current.clientWidth * 0.9 const viewportWidth = viewportRef.current.clientWidth * 0.9
const viewportHeight = viewportRef.current.clientHeight * 0.9 const viewportHeight = viewportRef.current.clientHeight * 0.9
@@ -51,14 +97,37 @@ export default function ImageWindow() {
} }
}, []) }, [])
// 视频挂载后再播放,避免点击瞬间 ref 尚未就绪导致丢播
useEffect(() => {
if (!isPlayingLive || !videoRef.current) return
const timer = window.setTimeout(() => {
const video = videoRef.current
if (!video || !isPlayingLive || !video.paused) return
video.currentTime = 0
void video.play().catch(() => {
stopLivePlayback(true)
})
}, 0)
return () => window.clearTimeout(timer)
}, [isPlayingLive, stopLivePlayback])
useEffect(() => {
return () => {
clearLiveCleanupTimer()
}
}, [clearLiveCleanupTimer])
// 使用原生事件监听器处理拖动 // 使用原生事件监听器处理拖动
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
if (!dragStateRef.current.isDragging) return if (!dragStateRef.current.isDragging) return
const dx = e.clientX - dragStateRef.current.startX const dx = e.clientX - dragStateRef.current.startX
const dy = e.clientY - dragStateRef.current.startY const dy = e.clientY - dragStateRef.current.startY
setPosition({ setPosition({
x: dragStateRef.current.startPosX + dx, x: dragStateRef.current.startPosX + dx,
y: dragStateRef.current.startPosY + dy y: dragStateRef.current.startPosY + dy
@@ -82,7 +151,7 @@ export default function ImageWindow() {
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return if (e.button !== 0) return
e.preventDefault() e.preventDefault()
dragStateRef.current = { dragStateRef.current = {
isDragging: true, isDragging: true,
startX: e.clientX, startX: e.clientX,
@@ -106,15 +175,25 @@ export default function ImageWindow() {
// 快捷键支持 // 快捷键支持
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') window.electronAPI.window.close() if (e.key === 'Escape') {
if (isPlayingLive) {
stopLivePlayback(true)
return
}
window.electronAPI.window.close()
}
if (e.key === '=' || e.key === '+') handleZoomIn() if (e.key === '=' || e.key === '+') handleZoomIn()
if (e.key === '-') handleZoomOut() if (e.key === '-') handleZoomOut()
if (e.key === 'r' || e.key === 'R') handleRotate() if (e.key === 'r' || e.key === 'R') handleRotate()
if (e.key === '0') handleReset() if (e.key === '0') handleReset()
if (e.key === ' ' && hasLiveVideo) {
e.preventDefault()
handlePlayLiveVideo()
}
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleReset]) }, [handleReset, hasLiveVideo, handlePlayLiveVideo, isPlayingLive, stopLivePlayback])
if (!imagePath) { if (!imagePath) {
return ( return (
@@ -131,6 +210,20 @@ export default function ImageWindow() {
<div className="title-bar"> <div className="title-bar">
<div className="window-drag-area"></div> <div className="window-drag-area"></div>
<div className="title-bar-controls"> <div className="title-bar-controls">
{hasLiveVideo && (
<>
<button
onClick={handlePlayLiveVideo}
title={isPlayingLive ? '正在播放实况' : '播放实况 (空格)'}
className={`live-play-btn ${isPlayingLive ? 'active' : ''}`}
disabled={isPlayingLive}
>
<LivePhotoIcon size={16} />
<span style={{ fontSize: 13, marginLeft: 4 }}>Live</span>
</button>
<div className="divider"></div>
</>
)}
<button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button> <button onClick={handleZoomOut} title="缩小 (-)"><ZoomOut size={16} /></button>
<span className="scale-text">{Math.round(displayScale * 100)}%</span> <span className="scale-text">{Math.round(displayScale * 100)}%</span>
<button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button> <button onClick={handleZoomIn} title="放大 (+)"><ZoomIn size={16} /></button>
@@ -140,22 +233,38 @@ export default function ImageWindow() {
</div> </div>
</div> </div>
<div <div
className="image-viewport" className="image-viewport"
ref={viewportRef} ref={viewportRef}
onWheel={handleWheel} onWheel={handleWheel}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
<img <div
src={imagePath} className="media-wrapper"
alt="Preview"
style={{ style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)` transform: `translate(${position.x}px, ${position.y}px) scale(${displayScale}) rotate(${rotation}deg)`
}} }}
onLoad={handleImageLoad} >
draggable={false} <img
/> src={imagePath}
alt="Preview"
onLoad={handleImageLoad}
draggable={false}
/>
{hasLiveVideo && isPlayingLive && (
<video
ref={videoRef}
src={liveVideoPath || ''}
className={`live-video ${isVideoVisible ? 'visible' : ''}`}
autoPlay
playsInline
preload="auto"
onPlaying={() => setIsVideoVisible(true)}
onEnded={() => stopLivePlayback(false)}
/>
)}
</div>
</div> </div>
</div> </div>
) )

View File

@@ -1,9 +1,11 @@
import { useEffect, useState, useRef } from 'react' import { useEffect, useState, useRef } from 'react'
import { NotificationToast, type NotificationData } from '../components/NotificationToast' import { NotificationToast, type NotificationData } from '../components/NotificationToast'
import { useThemeStore } from '../stores/themeStore'
import '../components/NotificationToast.scss' import '../components/NotificationToast.scss'
import './NotificationWindow.scss' import './NotificationWindow.scss'
export default function NotificationWindow() { export default function NotificationWindow() {
const { currentTheme, themeMode } = useThemeStore()
const [notification, setNotification] = useState<NotificationData | null>(null) const [notification, setNotification] = useState<NotificationData | null>(null)
const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null) const [prevNotification, setPrevNotification] = useState<NotificationData | null>(null)
@@ -17,6 +19,12 @@ export default function NotificationWindow() {
const notificationRef = useRef<NotificationData | null>(null) const notificationRef = useRef<NotificationData | null>(null)
// 应用主题到通知窗口
useEffect(() => {
document.documentElement.setAttribute('data-theme', currentTheme)
document.documentElement.setAttribute('data-mode', themeMode)
}, [currentTheme, themeMode])
useEffect(() => { useEffect(() => {
notificationRef.current = notification notificationRef.current = notification
}, [notification]) }, [notification])

View File

@@ -7,7 +7,7 @@ import { dialog } from '../services/ipc'
import * as configService from '../services/config' import * as configService from '../services/config'
import { import {
Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy,
RotateCcw, Trash2, Plug, Check, Sun, Moon, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor,
Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic, Palette, Database, Download, HardDrive, Info, RefreshCw, ChevronDown, Mic,
ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2 ShieldCheck, Fingerprint, Lock, KeyRound, Bell, Globe, BarChart2
} from 'lucide-react' } from 'lucide-react'
@@ -55,6 +55,14 @@ function SettingsPage() {
const resetChatStore = useChatStore((state) => state.reset) const resetChatStore = useChatStore((state) => state.reset)
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
const [systemDark, setSystemDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
const effectiveMode = themeMode === 'system' ? (systemDark ? 'dark' : 'light') : themeMode
const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache) const clearAnalyticsStoreCache = useAnalyticsStore((state) => state.clearCache)
const [activeTab, setActiveTab] = useState<SettingsTab>('appearance') const [activeTab, setActiveTab] = useState<SettingsTab>('appearance')
@@ -82,10 +90,6 @@ function SettingsPage() {
const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0) const [whisperDownloadProgress, setWhisperDownloadProgress] = useState(0)
const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 }) const [whisperProgressData, setWhisperProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null) const [whisperModelStatus, setWhisperModelStatus] = useState<{ exists: boolean; modelPath?: string; tokensPath?: string } | null>(null)
const [llamaModelStatus, setLlamaModelStatus] = useState<{ exists: boolean; path?: string; size?: number } | null>(null)
const [isLlamaDownloading, setIsLlamaDownloading] = useState(false)
const [llamaDownloadProgress, setLlamaDownloadProgress] = useState(0)
const [llamaProgressData, setLlamaProgressData] = useState<{ downloaded: number; total: number; speed: number }>({ downloaded: 0, total: 0, speed: 0 })
const formatBytes = (bytes: number) => { const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@@ -142,12 +146,18 @@ function SettingsPage() {
const [helloAvailable, setHelloAvailable] = useState(false) const [helloAvailable, setHelloAvailable] = useState(false)
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('') const [confirmPassword, setConfirmPassword] = useState('')
const [oldPassword, setOldPassword] = useState('')
const [helloPassword, setHelloPassword] = useState('')
const [disableLockPassword, setDisableLockPassword] = useState('')
const [showDisableLockInput, setShowDisableLockInput] = useState(false)
const [isLockMode, setIsLockMode] = useState(false)
const [isSettingHello, setIsSettingHello] = useState(false) const [isSettingHello, setIsSettingHello] = useState(false)
// HTTP API 设置 state // HTTP API 设置 state
const [httpApiEnabled, setHttpApiEnabled] = useState(false) const [httpApiEnabled, setHttpApiEnabled] = useState(false)
const [httpApiPort, setHttpApiPort] = useState(5031) const [httpApiPort, setHttpApiPort] = useState(5031)
const [httpApiRunning, setHttpApiRunning] = useState(false) const [httpApiRunning, setHttpApiRunning] = useState(false)
const [httpApiMediaExportPath, setHttpApiMediaExportPath] = useState('')
const [isTogglingApi, setIsTogglingApi] = useState(false) const [isTogglingApi, setIsTogglingApi] = useState(false)
const [showApiWarning, setShowApiWarning] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false)
@@ -169,6 +179,9 @@ function SettingsPage() {
if (status.port) { if (status.port) {
setHttpApiPort(status.port) setHttpApiPort(status.port)
} }
if (status.mediaExportPath) {
setHttpApiMediaExportPath(status.mediaExportPath)
}
} catch (e) { } catch (e) {
console.error('检查 API 状态失败:', e) console.error('检查 API 状态失败:', e)
} }
@@ -176,14 +189,6 @@ function SettingsPage() {
checkApiStatus() checkApiStatus()
}, []) }, [])
async function sha256(message: string) {
const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
useEffect(() => { useEffect(() => {
loadConfig() loadConfig()
loadAppVersion() loadAppVersion()
@@ -271,10 +276,12 @@ function SettingsPage() {
const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterMode = await configService.getNotificationFilterMode()
const savedNotificationFilterList = await configService.getNotificationFilterList() const savedNotificationFilterList = await configService.getNotificationFilterList()
const savedAuthEnabled = await configService.getAuthEnabled() const savedAuthEnabled = await window.electronAPI.auth.verifyEnabled()
const savedAuthUseHello = await configService.getAuthUseHello() const savedAuthUseHello = await configService.getAuthUseHello()
const savedIsLockMode = await window.electronAPI.auth.isLockMode()
setAuthEnabled(savedAuthEnabled) setAuthEnabled(savedAuthEnabled)
setAuthUseHello(savedAuthUseHello) setAuthUseHello(savedAuthUseHello)
setIsLockMode(savedIsLockMode)
if (savedPath) setDbPath(savedPath) if (savedPath) setDbPath(savedPath)
if (savedWxid) setWxid(savedWxid) if (savedWxid) setWxid(savedWxid)
@@ -324,8 +331,7 @@ function SettingsPage() {
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir) if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
// Load Llama status after config
void checkLlamaModelStatus()
} catch (e: any) { } catch (e: any) {
console.error('加载配置失败:', e) console.error('加载配置失败:', e)
} }
@@ -641,7 +647,6 @@ function SettingsPage() {
setWhisperModelDir(dir) setWhisperModelDir(dir)
await configService.setWhisperModelDir(dir) await configService.setWhisperModelDir(dir)
showMessage('已选择 Whisper 模型目录', true) showMessage('已选择 Whisper 模型目录', true)
await checkLlamaModelStatus()
} }
} catch (e: any) { } catch (e: any) {
showMessage('选择目录失败', false) showMessage('选择目录失败', false)
@@ -677,68 +682,6 @@ function SettingsPage() {
const handleResetWhisperModelDir = async () => { const handleResetWhisperModelDir = async () => {
setWhisperModelDir('') setWhisperModelDir('')
await configService.setWhisperModelDir('') await configService.setWhisperModelDir('')
await checkLlamaModelStatus()
}
const checkLlamaModelStatus = async () => {
try {
// @ts-ignore
const modelsPath = await window.electronAPI.llama?.getModelsPath()
if (!modelsPath) return
const modelName = "Qwen3-4B-Q4_K_M.gguf" // Hardcoded preset for now
const fullPath = `${modelsPath}\\${modelName}`
// @ts-ignore
const status = await window.electronAPI.llama?.getModelStatus(fullPath)
if (status) {
setLlamaModelStatus({
exists: status.exists,
path: status.path,
size: status.size
})
}
} catch (e) {
console.error("Check llama model status failed", e)
}
}
useEffect(() => {
const handleLlamaProgress = (payload: { downloaded: number; total: number; speed: number }) => {
setLlamaProgressData(payload)
if (payload.total > 0) {
setLlamaDownloadProgress((payload.downloaded / payload.total) * 100)
}
}
// @ts-ignore
const removeListener = window.electronAPI.llama?.onDownloadProgress(handleLlamaProgress)
return () => {
if (typeof removeListener === 'function') removeListener()
}
}, [])
const handleDownloadLlamaModel = async () => {
if (isLlamaDownloading) return
setIsLlamaDownloading(true)
setLlamaDownloadProgress(0)
try {
const modelUrl = "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf"
// @ts-ignore
const modelsPath = await window.electronAPI.llama?.getModelsPath()
const modelName = "Qwen3-4B-Q4_K_M.gguf"
const fullPath = `${modelsPath}\\${modelName}`
// @ts-ignore
const result = await window.electronAPI.llama?.downloadModel(modelUrl, fullPath)
if (result?.success) {
showMessage('Qwen3 模型下载完成', true)
await checkLlamaModelStatus()
} else {
showMessage(`模型下载失败: ${result?.error || '未知错误'}`, false)
}
} catch (e: any) {
showMessage(`模型下载失败: ${e}`, false)
} finally {
setIsLlamaDownloading(false)
}
} }
const handleAutoGetDbKey = async () => { const handleAutoGetDbKey = async () => {
@@ -989,11 +932,14 @@ function SettingsPage() {
<button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}> <button className={`mode-btn ${themeMode === 'dark' ? 'active' : ''}`} onClick={() => setThemeMode('dark')}>
<Moon size={16} /> <Moon size={16} />
</button> </button>
<button className={`mode-btn ${themeMode === 'system' ? 'active' : ''}`} onClick={() => setThemeMode('system')}>
<Monitor size={16} />
</button>
</div> </div>
<div className="theme-grid"> <div className="theme-grid">
{themes.map((theme) => ( {themes.map((theme) => (
<div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}> <div key={theme.id} className={`theme-card ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => setTheme(theme.id)}>
<div className="theme-preview" style={{ background: themeMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}> <div className="theme-preview" style={{ background: effectiveMode === 'dark' ? 'linear-gradient(135deg, #1a1a1a 0%, #2a2a2a 100%)' : `linear-gradient(135deg, ${theme.bgColor} 0%, ${theme.bgColor}dd 100%)` }}>
<div className="theme-accent" style={{ background: theme.primaryColor }} /> <div className="theme-accent" style={{ background: theme.primaryColor }} />
</div> </div>
<div className="theme-info"> <div className="theme-info">
@@ -1437,7 +1383,7 @@ function SettingsPage() {
<div className="tab-content"> <div className="tab-content">
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"> AI </span> <span className="form-hint"></span>
</div> </div>
<div className="form-group"> <div className="form-group">
@@ -1507,50 +1453,6 @@ function SettingsPage() {
</div> </div>
</div> </div>
<div className="form-group">
<label>AI (Llama)</label>
<span className="form-hint"> AI </span>
<div className="setting-control vertical has-border">
<div className="model-status-card">
<div className="model-info">
<div className="model-name">Qwen3 4B (Preset) (~2.6GB)</div>
<div className="model-path">
{llamaModelStatus?.exists ? (
<span className="status-indicator success"><Check size={14} /> </span>
) : (
<span className="status-indicator warning"></span>
)}
{llamaModelStatus?.path && <div className="path-text" title={llamaModelStatus.path}>{llamaModelStatus.path}</div>}
</div>
</div>
<div className="model-actions">
{!llamaModelStatus?.exists && !isLlamaDownloading && (
<button
className="btn-download"
onClick={handleDownloadLlamaModel}
>
<Download size={16} />
</button>
)}
{isLlamaDownloading && (
<div className="download-status">
<div className="status-header">
<span className="percent">{Math.floor(llamaDownloadProgress)}%</span>
<span className="metrics">
{formatBytes(llamaProgressData.downloaded)} / {formatBytes(llamaProgressData.total)}
<span className="speed">({formatBytes(llamaProgressData.speed)}/s)</span>
</span>
</div>
<div className="progress-bar-mini">
<div className="fill" style={{ width: `${llamaDownloadProgress}%` }}></div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
<div className="form-group"> <div className="form-group">
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint"></span>
@@ -1978,6 +1880,17 @@ function SettingsPage() {
)} )}
{/* API 安全警告弹窗 */} {/* API 安全警告弹窗 */}
<div className="form-group">
<label></label>
<span className="form-hint">`/api/v1/messages` `media=1` </span>
<input
type="text"
className="field-input"
value={httpApiMediaExportPath || '未获取到目录'}
readOnly
/>
</div>
{showApiWarning && ( {showApiWarning && (
<div className="modal-overlay" onClick={() => setShowApiWarning(false)}> <div className="modal-overlay" onClick={() => setShowApiWarning(false)}>
<div className="api-warning-modal" onClick={(e) => e.stopPropagation()}> <div className="api-warning-modal" onClick={(e) => e.stopPropagation()}>
@@ -2017,6 +1930,10 @@ function SettingsPage() {
) )
const handleSetupHello = async () => { const handleSetupHello = async () => {
if (!helloPassword) {
showMessage('请输入当前密码以开启 Hello', false)
return
}
setIsSettingHello(true) setIsSettingHello(true)
try { try {
const challenge = new Uint8Array(32) const challenge = new Uint8Array(32)
@@ -2034,8 +1951,10 @@ function SettingsPage() {
}) })
if (credential) { if (credential) {
// 存储密码作为 Hello Secret以便 Hello 解锁时能派生密钥
await window.electronAPI.auth.setHelloSecret(helloPassword)
setAuthUseHello(true) setAuthUseHello(true)
await configService.setAuthUseHello(true) setHelloPassword('')
showMessage('Windows Hello 设置成功', true) showMessage('Windows Hello 设置成功', true)
} }
} catch (e: any) { } catch (e: any) {
@@ -2053,18 +1972,40 @@ function SettingsPage() {
return return
} }
// 简单的保存逻辑,实际上应该先验证旧密码,但为了简化流程,这里直接允许覆盖
// 因为能进入设置页面说明已经解锁了
try { try {
const hash = await sha256(newPassword) const lockMode = await window.electronAPI.auth.isLockMode()
await configService.setAuthPassword(hash)
await configService.setAuthEnabled(true) if (authEnabled && lockMode) {
setAuthEnabled(true) // 已开启应用锁且已是 lock: 模式 → 修改密码
setNewPassword('') if (!oldPassword) {
setConfirmPassword('') showMessage('请输入旧密码', false)
showMessage('密码已更新', true) return
}
const result = await window.electronAPI.auth.changePassword(oldPassword, newPassword)
if (result.success) {
setNewPassword('')
setConfirmPassword('')
setOldPassword('')
showMessage('密码已更新', true)
} else {
showMessage(result.error || '密码更新失败', false)
}
} else {
// 未开启应用锁,或旧版 safe: 模式 → 开启/升级为 lock: 模式
const result = await window.electronAPI.auth.enableLock(newPassword)
if (result.success) {
setAuthEnabled(true)
setIsLockMode(true)
setNewPassword('')
setConfirmPassword('')
setOldPassword('')
showMessage('应用锁已开启', true)
} else {
showMessage(result.error || '开启失败', false)
}
}
} catch (e: any) { } catch (e: any) {
showMessage('密码更新失败', false) showMessage('操作失败', false)
} }
} }
@@ -2123,31 +2064,73 @@ function SettingsPage() {
<div className="form-group"> <div className="form-group">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div> <div>
<label></label> <label></label>
<span className="form-hint"></span> <span className="form-hint">{
isLockMode ? '已开启' :
authEnabled ? '旧版模式 — 请重新设置密码以升级为新模式提高安全性' :
'未开启 — 请设置密码以开启'
}</span>
</div> </div>
<label className="switch"> {authEnabled && !showDisableLockInput && (
<input <button
type="checkbox" className="btn btn-secondary btn-sm"
checked={authEnabled} onClick={() => setShowDisableLockInput(true)}
onChange={async (e) => { >
const enabled = e.target.checked
setAuthEnabled(enabled) </button>
await configService.setAuthEnabled(enabled) )}
}}
/>
<span className="switch-slider" />
</label>
</div> </div>
{showDisableLockInput && (
<div style={{ marginTop: 10, display: 'flex', gap: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以关闭"
value={disableLockPassword}
onChange={e => setDisableLockPassword(e.target.value)}
style={{ flex: 1 }}
/>
<button
className="btn btn-primary btn-sm"
disabled={!disableLockPassword}
onClick={async () => {
const result = await window.electronAPI.auth.disableLock(disableLockPassword)
if (result.success) {
setAuthEnabled(false)
setAuthUseHello(false)
setIsLockMode(false)
setShowDisableLockInput(false)
setDisableLockPassword('')
showMessage('应用锁已关闭', true)
} else {
showMessage(result.error || '关闭失败', false)
}
}}
></button>
<button
className="btn btn-secondary btn-sm"
onClick={() => { setShowDisableLockInput(false); setDisableLockPassword('') }}
></button>
</div>
)}
</div> </div>
<div className="divider" /> <div className="divider" />
<div className="form-group"> <div className="form-group">
<label></label> <label>{isLockMode ? '修改密码' : '设置密码并开启应用锁'}</label>
<span className="form-hint"></span> <span className="form-hint">{isLockMode ? '修改应用锁密码(需要旧密码验证)' : '设置密码后将自动开启应用锁'}</span>
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}> <div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 10 }}>
{isLockMode && (
<input
type="password"
className="field-input"
placeholder="旧密码"
value={oldPassword}
onChange={e => setOldPassword(e.target.value)}
/>
)}
<input <input
type="password" type="password"
className="field-input" className="field-input"
@@ -2164,7 +2147,9 @@ function SettingsPage() {
onChange={e => setConfirmPassword(e.target.value)} onChange={e => setConfirmPassword(e.target.value)}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}></button> <button className="btn btn-primary" onClick={handleUpdatePassword} disabled={!newPassword}>
{isLockMode ? '更新' : '开启'}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -2176,23 +2161,39 @@ function SettingsPage() {
<div> <div>
<label>Windows Hello</label> <label>Windows Hello</label>
<span className="form-hint">使</span> <span className="form-hint">使</span>
{!helloAvailable && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>} {!authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}></div>}
{!helloAvailable && authEnabled && <div className="form-hint warning" style={{ color: '#ff4d4f' }}> Windows Hello</div>}
</div> </div>
<div> <div>
{authUseHello ? ( {authUseHello ? (
<button className="btn btn-secondary btn-sm" onClick={() => setAuthUseHello(false)}></button> <button className="btn btn-secondary btn-sm" onClick={async () => {
await window.electronAPI.auth.clearHelloSecret()
setAuthUseHello(false)
showMessage('Windows Hello 已关闭', true)
}}></button>
) : ( ) : (
<button <button
className="btn btn-secondary btn-sm" className="btn btn-secondary btn-sm"
onClick={handleSetupHello} onClick={handleSetupHello}
disabled={!helloAvailable || isSettingHello} disabled={!helloAvailable || isSettingHello || !authEnabled || !helloPassword}
> >
{isSettingHello ? '设置中...' : '开启与设置'} {isSettingHello ? '设置中...' : '开启与设置'}
</button> </button>
)} )}
</div> </div>
</div> </div>
{!authUseHello && authEnabled && (
<div style={{ marginTop: 10 }}>
<input
type="password"
className="field-input"
placeholder="输入当前密码以开启 Hello"
value={helloPassword}
onChange={e => setHelloPassword(e.target.value)}
/>
</div>
)}
</div> </div>
</div> </div>
) )

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +0,0 @@
export interface ModelInfo {
name: string;
path: string;
downloadUrl?: string; // If it's a known preset
size?: number;
downloaded: boolean;
}
export const PRESET_MODELS: ModelInfo[] = [
{
name: "Qwen3 4B (Preset)",
path: "Qwen3-4B-Q4_K_M.gguf",
downloadUrl: "https://www.modelscope.cn/models/Qwen/Qwen3-4B-GGUF/resolve/master/Qwen3-4B-Q4_K_M.gguf",
downloaded: false
}
];
class EngineService {
private onTokenCallback: ((token: string) => void) | null = null;
private onProgressCallback: ((percent: number) => void) | null = null;
private _removeTokenListener: (() => void) | null = null;
private _removeProgressListener: (() => void) | null = null;
constructor() {
// Initialize listeners
this._removeTokenListener = window.electronAPI.llama.onToken((token: string) => {
if (this.onTokenCallback) {
this.onTokenCallback(token);
}
});
this._removeProgressListener = window.electronAPI.llama.onDownloadProgress((percent: number) => {
if (this.onProgressCallback) {
this.onProgressCallback(percent);
}
});
}
public async checkModelExists(filename: string): Promise<boolean> {
const modelsPath = await window.electronAPI.llama.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`; // Windows path separator
// We might need to handle path separator properly or let main process handle it
// Updated preload to take full path or handling in main?
// Let's rely on main process exposing join or just checking relative to models dir if implemented
// Actually main process `checkFileExists` takes a path.
// Let's assume we construct path here or Main helps.
// Better: getModelsPath returns the directory.
return await window.electronAPI.llama.checkFileExists(fullPath);
}
public async getModelsPath(): Promise<string> {
return await window.electronAPI.llama.getModelsPath();
}
public async loadModel(filename: string) {
const modelsPath = await this.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`;
console.log("Loading model:", fullPath);
return await window.electronAPI.llama.loadModel(fullPath);
}
public async createSession(systemPrompt?: string) {
return await window.electronAPI.llama.createSession(systemPrompt);
}
public async chat(message: string, onToken: (token: string) => void, options?: { thinking?: boolean }) {
this.onTokenCallback = onToken;
return await window.electronAPI.llama.chat(message, options);
}
public async downloadModel(url: string, filename: string, onProgress: (percent: number) => void) {
const modelsPath = await this.getModelsPath();
const fullPath = `${modelsPath}\\${filename}`;
this.onProgressCallback = onProgress;
return await window.electronAPI.llama.downloadModel(url, fullPath);
}
/**
* 清除当前的回调函数引用
* 用于避免内存泄漏
*/
public clearCallbacks() {
this.onTokenCallback = null;
this.onProgressCallback = null;
}
/**
* 释放所有资源
* 包括事件监听器和回调引用
*/
public dispose() {
// 清除回调
this.clearCallbacks();
// 移除事件监听器
if (this._removeTokenListener) {
this._removeTokenListener();
this._removeTokenListener = null;
}
if (this._removeProgressListener) {
this._removeProgressListener();
this._removeProgressListener = null;
}
}
}
export const engineService = new EngineService();

View File

@@ -118,13 +118,13 @@ export async function setWxidConfig(wxid: string, configValue: WxidConfig): Prom
} }
// 获取主题 // 获取主题
export async function getTheme(): Promise<'light' | 'dark'> { export async function getTheme(): Promise<'light' | 'dark' | 'system'> {
const value = await config.get(CONFIG_KEYS.THEME) const value = await config.get(CONFIG_KEYS.THEME)
return (value as 'light' | 'dark') || 'light' return (value as 'light' | 'dark' | 'system') || 'light'
} }
// 设置主题 // 设置主题
export async function setTheme(theme: 'light' | 'dark'): Promise<void> { export async function setTheme(theme: 'light' | 'dark' | 'system'): Promise<void> {
await config.set(CONFIG_KEYS.THEME, theme) await config.set(CONFIG_KEYS.THEME, theme)
} }

View File

@@ -0,0 +1,64 @@
import { create } from 'zustand'
export interface BatchImageDecryptState {
isBatchDecrypting: boolean
progress: { current: number; total: number }
showToast: boolean
showResultToast: boolean
result: { success: number; fail: number }
startTime: number
sessionName: string
startDecrypt: (total: number, sessionName: string) => void
updateProgress: (current: number, total: number) => void
finishDecrypt: (success: number, fail: number) => void
setShowToast: (show: boolean) => void
setShowResultToast: (show: boolean) => void
reset: () => void
}
export const useBatchImageDecryptStore = create<BatchImageDecryptState>((set) => ({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: '',
startDecrypt: (total, sessionName) => set({
isBatchDecrypting: true,
progress: { current: 0, total },
showToast: true,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: Date.now(),
sessionName
}),
updateProgress: (current, total) => set({
progress: { current, total }
}),
finishDecrypt: (success, fail) => set({
isBatchDecrypting: false,
showToast: false,
showResultToast: true,
result: { success, fail },
startTime: 0
}),
setShowToast: (show) => set({ showToast: show }),
setShowResultToast: (show) => set({ showResultToast: show }),
reset: () => set({
isBatchDecrypting: false,
progress: { current: 0, total: 0 },
showToast: false,
showResultToast: false,
result: { success: 0, fail: 0 },
startTime: 0,
sessionName: ''
})
}))

View File

@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware' import { persist } from 'zustand/middleware'
export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water' export type ThemeId = 'cloud-dancer' | 'corundum-blue' | 'kiwi-green' | 'spicy-red' | 'teal-water'
export type ThemeMode = 'light' | 'dark' export type ThemeMode = 'light' | 'dark' | 'system'
export interface ThemeInfo { export interface ThemeInfo {
id: ThemeId id: ThemeId

View File

@@ -167,6 +167,50 @@
} }
} }
.batch-inline-result-toast {
.batch-progress-toast-title {
svg {
color: #22c55e;
}
}
.batch-inline-result-summary {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.batch-inline-result-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-tertiary);
font-size: 12px;
color: var(--text-secondary);
svg {
flex-shrink: 0;
}
&.success {
color: #16a34a;
svg { color: #16a34a; }
}
&.fail {
color: #dc2626;
svg { color: #dc2626; }
}
&.muted {
color: var(--text-tertiary, #999);
svg { color: var(--text-tertiary, #999); }
}
}
}
// 批量转写结果对话框 // 批量转写结果对话框
.batch-result-modal { .batch-result-modal {
width: 420px; width: 420px;
@@ -293,4 +337,4 @@
} }
} }
} }
} }

View File

@@ -37,6 +37,8 @@
// 卡片背景 // 卡片背景
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAFAF7;
--sent-card-bg: var(--primary);
} }
// ==================== 浅色主题 ==================== // ==================== 浅色主题 ====================
@@ -59,6 +61,8 @@
--bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%); --bg-gradient: linear-gradient(135deg, #F0EEE9 0%, #E8E6E1 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #A68B5B 100%);
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAFAF7;
--sent-card-bg: var(--primary);
} }
// 刚玉蓝主题 // 刚玉蓝主题
@@ -79,6 +83,8 @@
--bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%); --bg-gradient: linear-gradient(135deg, #E8EEF0 0%, #D8E4E8 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #5A7A86 100%);
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F8FAFB;
--sent-card-bg: var(--primary);
} }
// 冰猕猴桃汁绿主题 // 冰猕猴桃汁绿主题
@@ -99,6 +105,8 @@
--bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%); --bg-gradient: linear-gradient(135deg, #E8F0E4 0%, #D8E8D0 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #8AAA6C 100%);
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F8FBF6;
--sent-card-bg: var(--primary);
} }
// 辛辣红主题 // 辛辣红主题
@@ -119,6 +127,8 @@
--bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%); --bg-gradient: linear-gradient(135deg, #F0E8E8 0%, #E8D8D8 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #A05058 100%);
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #FAF8F8;
--sent-card-bg: var(--primary);
} }
// 明水鸭色主题 // 明水鸭色主题
@@ -139,6 +149,8 @@
--bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%); --bg-gradient: linear-gradient(135deg, #E4F0F0 0%, #D4E8E8 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #6A9A9A 100%);
--card-bg: rgba(255, 255, 255, 0.7); --card-bg: rgba(255, 255, 255, 0.7);
--card-inner-bg: #F6FBFB;
--sent-card-bg: var(--primary);
} }
// ==================== 深色主题 ==================== // ==================== 深色主题 ====================
@@ -160,6 +172,8 @@
--bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%); --bg-gradient: linear-gradient(135deg, #1a1816 0%, #252220 100%);
--primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%); --primary-gradient: linear-gradient(135deg, #8B7355 0%, #C9A86C 100%);
--card-bg: rgba(40, 36, 32, 0.9); --card-bg: rgba(40, 36, 32, 0.9);
--card-inner-bg: #27231F;
--sent-card-bg: var(--primary);
} }
// 刚玉蓝 - 深色 // 刚玉蓝 - 深色
@@ -179,6 +193,8 @@
--bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%); --bg-gradient: linear-gradient(135deg, #141a1c 0%, #1e282c 100%);
--primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%); --primary-gradient: linear-gradient(135deg, #4A6670 0%, #6A9AAA 100%);
--card-bg: rgba(30, 40, 44, 0.9); --card-bg: rgba(30, 40, 44, 0.9);
--card-inner-bg: #1D272A;
--sent-card-bg: var(--primary);
} }
// 冰猕猴桃汁绿 - 深色 // 冰猕猴桃汁绿 - 深色
@@ -198,6 +214,8 @@
--bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%); --bg-gradient: linear-gradient(135deg, #161a14 0%, #222a1e 100%);
--primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%); --primary-gradient: linear-gradient(135deg, #7A9A5C 0%, #9ABA7C 100%);
--card-bg: rgba(34, 42, 30, 0.9); --card-bg: rgba(34, 42, 30, 0.9);
--card-inner-bg: #21281D;
--sent-card-bg: var(--primary);
} }
// 辛辣红 - 深色 // 辛辣红 - 深色
@@ -217,6 +235,8 @@
--bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%); --bg-gradient: linear-gradient(135deg, #1a1416 0%, #2a2022 100%);
--primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%); --primary-gradient: linear-gradient(135deg, #8B4049 0%, #C06068 100%);
--card-bg: rgba(42, 32, 34, 0.9); --card-bg: rgba(42, 32, 34, 0.9);
--card-inner-bg: #281F21;
--sent-card-bg: var(--primary);
} }
// 明水鸭色 - 深色 // 明水鸭色 - 深色
@@ -236,6 +256,8 @@
--bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%); --bg-gradient: linear-gradient(135deg, #121a1a 0%, #1c2a2a 100%);
--primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%); --primary-gradient: linear-gradient(135deg, #5A8A8A 0%, #7ABAAA 100%);
--card-bg: rgba(28, 42, 42, 0.9); --card-bg: rgba(28, 42, 42, 0.9);
--card-inner-bg: #1B2828;
--sent-card-bg: var(--primary);
} }
// 重置样式 // 重置样式

View File

@@ -11,7 +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> openImageViewerWindow: (imagePath: string, liveVideoPath?: string) => Promise<void>
openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean> openChatHistoryWindow: (sessionId: string, messageId: number) => Promise<boolean>
} }
config: { config: {
@@ -19,6 +19,17 @@ export interface ElectronAPI {
set: (key: string, value: unknown) => Promise<void> set: (key: string, value: unknown) => Promise<void>
clear: () => Promise<boolean> clear: () => Promise<boolean>
} }
auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
setHelloSecret: (password: string) => Promise<{ success: boolean }>
clearHelloSecret: () => Promise<{ success: boolean }>
isLockMode: () => Promise<boolean>
}
dialog: { dialog: {
openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue> openFile: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue> openDirectory: (options?: Electron.OpenDialogOptions) => Promise<Electron.OpenDialogReturnValue>
@@ -115,6 +126,11 @@ export interface ElectronAPI {
getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }>
getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }>
getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }> getAllVoiceMessages: (sessionId: string) => Promise<{ success: boolean; messages?: Message[]; error?: string }>
getAllImageMessages: (sessionId: string) => Promise<{
success: boolean
images?: { imageMd5?: string; imageDatName?: string; createTime?: number }[]
error?: string
}>
resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }>
getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }>
onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void
@@ -125,7 +141,7 @@ export interface ElectronAPI {
image: { image: {
decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }> decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => Promise<{ success: boolean; localPath?: string; error?: string }>
resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; error?: string }> resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => Promise<{ success: boolean; localPath?: string; hasUpdate?: boolean; liveVideoPath?: string; error?: string }>
preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean> preload: (payloads: Array<{ sessionId?: string; imageMd5?: string; imageDatName?: string }>) => Promise<boolean>
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void
@@ -275,6 +291,17 @@ export interface ElectronAPI {
count?: number count?: number
error?: string error?: string
}> }>
exportGroupMemberMessages: (
chatroomId: string,
memberUsername: string,
outputPath: string,
startTime?: number,
endTime?: number
) => Promise<{
success: boolean
count?: number
error?: string
}>
} }
annualReport: { annualReport: {
getAvailableYears: () => Promise<{ getAvailableYears: () => Promise<{
@@ -433,7 +460,7 @@ export interface ElectronAPI {
success: boolean success: boolean
error?: string error?: string
}> }>
exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean } }) => Promise<{ exportContacts: (outputDir: string, options: { format: 'json' | 'csv' | 'vcf'; exportAvatars: boolean; contactTypes: { friends: boolean; groups: boolean; officials: boolean }; selectedUsernames?: string[] }) => Promise<{
success: boolean success: boolean
successCount?: number successCount?: number
error?: string error?: string
@@ -480,27 +507,37 @@ export interface ElectronAPI {
}> }>
debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }> debugResource: (url: string) => Promise<{ success: boolean; status?: number; headers?: any; error?: string }>
proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }> proxyImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; dataUrl?: string; error?: string }>
downloadImage: (payload: { url: string; key?: string | number }) => Promise<{ success: boolean; data?: any; contentType?: string; error?: string }>
exportTimeline: (options: {
outputDir: string
format: 'json' | 'html'
usernames?: string[]
keyword?: string
exportMedia?: boolean
startTime?: number
endTime?: number
}) => Promise<{ success: boolean; filePath?: string; postCount?: number; mediaCount?: number; error?: string }>
onExportProgress: (callback: (payload: { current: number; total: number; status: string }) => void) => () => void
selectExportDir: () => Promise<{ canceled: boolean; filePath?: string }>
getSnsUsernames: () => Promise<{ success: boolean; usernames?: string[]; error?: string }>
} }
llama: { http: {
loadModel: (modelPath: string) => Promise<boolean> start: (port?: number) => Promise<{ success: boolean; port?: number; error?: string }>
createSession: (systemPrompt?: string) => Promise<boolean> stop: () => Promise<{ success: boolean }>
chat: (message: string) => Promise<{ success: boolean; response?: any; error?: string }> status: () => Promise<{ running: boolean; port: number; mediaExportPath: string }>
downloadModel: (url: string, savePath: string) => Promise<void>
getModelsPath: () => Promise<string>
checkFileExists: (filePath: string) => Promise<boolean>
getModelStatus: (modelPath: string) => Promise<{ exists: boolean; path?: string; size?: number; error?: string }>
onToken: (callback: (token: string) => void) => () => void
onDownloadProgress: (callback: (payload: { downloaded: number; total: number; speed: number }) => void) => () => void
} }
} }
export interface ExportOptions { export interface ExportOptions {
format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'sql' format: 'chatlab' | 'chatlab-jsonl' | 'json' | 'html' | 'txt' | 'excel' | 'weclone' | 'sql'
dateRange?: { start: number; end: number } | null dateRange?: { start: number; end: number } | null
senderUsername?: string
fileNameSuffix?: string
exportMedia?: boolean exportMedia?: boolean
exportAvatars?: boolean exportAvatars?: boolean
exportImages?: boolean exportImages?: boolean
exportVoices?: boolean exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean exportEmojis?: boolean
exportVoiceAsText?: boolean exportVoiceAsText?: boolean
excelCompactColumns?: boolean excelCompactColumns?: boolean

View File

@@ -32,7 +32,7 @@ export interface ContactInfo {
remark?: string remark?: string
nickname?: string nickname?: string
avatarUrl?: string avatarUrl?: string
type: 'friend' | 'group' | 'official' | 'other' type: 'friend' | 'group' | 'official' | 'former_friend' | 'other'
} }
// 消息 // 消息
@@ -64,12 +64,39 @@ export interface Message {
fileSize?: number // 文件大小 fileSize?: number // 文件大小
fileExt?: string // 文件扩展名 fileExt?: string // 文件扩展名
xmlType?: string // XML 中的 type 字段 xmlType?: string // XML 中的 type 字段
appMsgKind?: string // 归一化 appmsg 类型
appMsgDesc?: string
appMsgAppName?: string
appMsgSourceName?: string
appMsgSourceUsername?: string
appMsgThumbUrl?: string
appMsgMusicUrl?: string
appMsgDataUrl?: string
appMsgLocationLabel?: string
finderNickname?: string
finderUsername?: string
finderCoverUrl?: string // 视频号封面图
finderAvatar?: string // 视频号作者头像
finderDuration?: number // 视频号时长(秒)
// 位置消息
locationLat?: number // 纬度
locationLng?: number // 经度
locationPoiname?: string // 地点名称
locationLabel?: string // 详细地址
// 音乐消息
musicAlbumUrl?: string // 专辑封面
musicUrl?: string // 播放链接
// 礼物消息
giftImageUrl?: string // 礼物商品图
giftWish?: string // 祝福语
giftPrice?: string // 价格(分)
// 转账消息 // 转账消息
transferPayerUsername?: string // 转账付款方 wxid transferPayerUsername?: string // 转账付款方 wxid
transferReceiverUsername?: string // 转账收款方 wxid transferReceiverUsername?: string // 转账收款方 wxid
// 名片消息 // 名片消息
cardUsername?: string // 名片的微信ID cardUsername?: string // 名片的微信ID
cardNickname?: string // 名片的昵称 cardNickname?: string // 名片的昵称
cardAvatarUrl?: string // 名片头像 URL
// 聊天记录 // 聊天记录
chatRecordTitle?: string // 聊天记录标题 chatRecordTitle?: string // 聊天记录标题
chatRecordList?: ChatRecordItem[] // 聊天记录列表 chatRecordList?: ChatRecordItem[] // 聊天记录列表

47
src/types/sns.ts Normal file
View File

@@ -0,0 +1,47 @@
export interface SnsLivePhoto {
url: string
thumb: string
token?: string
key?: string
encIdx?: string
}
export interface SnsMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: SnsLivePhoto
}
export interface SnsComment {
id: string
nickname: string
content: string
refCommentId: string
refNickname?: string
}
export interface SnsPost {
id: string
username: string
nickname: string
avatarUrl?: string
createTime: number
contentDesc: string
type?: number
media: SnsMedia[]
likes: string[]
comments: SnsComment[]
rawXml?: string
linkTitle?: string
linkUrl?: string
}
export interface SnsLinkCardData {
title: string
url: string
thumb?: string
}

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

@@ -5,6 +5,14 @@ interface Window {
// ... other methods ... // ... other methods ...
auth: { auth: {
hello: (message?: string) => Promise<{ success: boolean; error?: string }> hello: (message?: string) => Promise<{ success: boolean; error?: string }>
verifyEnabled: () => Promise<boolean>
unlock: (password: string) => Promise<{ success: boolean; error?: string }>
enableLock: (password: string) => Promise<{ success: boolean; error?: string }>
disableLock: (password: string) => Promise<{ success: boolean; error?: string }>
changePassword: (oldPassword: string, newPassword: string) => Promise<{ success: boolean; error?: string }>
setHelloSecret: (password: string) => Promise<{ success: boolean }>
clearHelloSecret: () => Promise<{ success: boolean }>
isLockMode: () => Promise<boolean>
} }
// For brevity, using 'any' for other parts or properly importing types if available. // 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 // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts